From b58b8104e4296388cbe355969df524506402074a Mon Sep 17 00:00:00 2001 From: RyanGarfinkel <113050972+RyanGarfinkel@users.noreply.github.com> Date: Sun, 12 Jul 2026 23:12:49 -0400 Subject: [PATCH 01/35] Add second-pass tests for encryption Signed-off-by: RyanGarfinkel <113050972+RyanGarfinkel@users.noreply.github.com> --- .../system/security/encryption/conftest.py | 9 ++ .../encryption/test_encryption_crud.py | 135 ++++++++++++++++++ .../encryption/test_encryption_edge_cases.py | 112 +++++++++++++++ .../encryption/test_encryption_query.py | 92 ++++++++++++ .../security/encryption/utils/__init__.py | 0 .../encryption/utils/qe_collections.py | 64 +++++++++ 6 files changed, 412 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/system/security/encryption/conftest.py create mode 100644 documentdb_tests/compatibility/tests/system/security/encryption/test_encryption_crud.py create mode 100644 documentdb_tests/compatibility/tests/system/security/encryption/test_encryption_edge_cases.py create mode 100644 documentdb_tests/compatibility/tests/system/security/encryption/test_encryption_query.py create mode 100644 documentdb_tests/compatibility/tests/system/security/encryption/utils/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/security/encryption/utils/qe_collections.py diff --git a/documentdb_tests/compatibility/tests/system/security/encryption/conftest.py b/documentdb_tests/compatibility/tests/system/security/encryption/conftest.py new file mode 100644 index 000000000..0dab3e11d --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/security/encryption/conftest.py @@ -0,0 +1,9 @@ +"""Shared fixtures for Queryable Encryption tests in this directory.""" + +from documentdb_tests.compatibility.tests.system.security.encryption.utils.qe_collections import ( + qe_collection, + qe_collection_multi, + qe_collection_nested, +) + +__all__ = ["qe_collection", "qe_collection_multi", "qe_collection_nested"] diff --git a/documentdb_tests/compatibility/tests/system/security/encryption/test_encryption_crud.py b/documentdb_tests/compatibility/tests/system/security/encryption/test_encryption_crud.py new file mode 100644 index 000000000..101700da6 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/security/encryption/test_encryption_crud.py @@ -0,0 +1,135 @@ +"""Tests for CRUD operations against Queryable Encryption collections. + +Verifies insert, update, and delete operations with encryption feature. Any +plaintext value at an encrypted path is rejected, including null and regardless +of bsonType. Absent fields are unaffected. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode, assertSuccessPartial +from documentdb_tests.framework.error_codes import DOCUMENT_VALIDATION_FAILURE_ERROR +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = pytest.mark.requires(queryable_encryption=True) + + +# Property [Missing Field Acceptance]: insert succeeds when an encrypted field is absent. +@pytest.mark.insert +def test_encryption_insert_missing_field_succeeds(qe_collection): + """Test insert succeeds when the encrypted field is entirely absent.""" + result = execute_command( + qe_collection, {"insert": qe_collection.name, "documents": [{"_id": 1}]} + ) + assertSuccessPartial( + result, + {"ok": 1.0, "n": 1}, + msg="insert should accept a document missing the encrypted field.", + ) + + +# Property [Plaintext Rejection]: insert rejects null and any plaintext value at an +# encrypted path, regardless of bsonType. +INSERT_PLAINTEXT_REJECTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "null_value", + command=lambda ctx: {"insert": ctx.collection, "documents": [{"_id": 1, "ssn": None}]}, + error_code=DOCUMENT_VALIDATION_FAILURE_ERROR, + msg="insert should reject an explicit null at an encrypted path.", + ), + CommandTestCase( + "plaintext_matching_bsontype", + command=lambda ctx: { + "insert": ctx.collection, + "documents": [{"_id": 2, "ssn": "123-45-6789"}], + }, + error_code=DOCUMENT_VALIDATION_FAILURE_ERROR, + msg="insert should reject a plaintext value even when it matches the declared bsonType.", + ), + CommandTestCase( + "plaintext_wrong_bsontype", + command=lambda ctx: {"insert": ctx.collection, "documents": [{"_id": 3, "ssn": 123}]}, + error_code=DOCUMENT_VALIDATION_FAILURE_ERROR, + msg="insert should reject a plaintext value of the wrong bsonType.", + ), +] + + +@pytest.mark.insert +@pytest.mark.parametrize("test", pytest_params(INSERT_PLAINTEXT_REJECTION_TESTS)) +def test_encryption_insert_rejects_plaintext(qe_collection, test: CommandTestCase): + """Test insert rejects plaintext values at an encrypted path.""" + ctx = CommandContext.from_collection(qe_collection) + result = execute_command(qe_collection, test.build_command(ctx)) + assertFailureCode(result, test.error_code, msg=test.msg) + + +# Property [Multi-Field Independence]: each declared encrypted field is validated +# independently of the others. +@pytest.mark.insert +def test_encryption_insert_multiple_fields_all_absent(qe_collection_multi): + """Test insert succeeds when every declared encrypted field is absent.""" + result = execute_command( + qe_collection_multi, + {"insert": qe_collection_multi.name, "documents": [{"_id": 1, "name": "a"}]}, + ) + assertSuccessPartial( + result, + {"ok": 1.0, "n": 1}, + msg="insert should accept a document missing every encrypted field.", + ) + + +@pytest.mark.insert +def test_encryption_insert_one_of_multiple_fields_plaintext_rejected(qe_collection_multi): + """Test insert rejects a plaintext value on one of several encrypted fields.""" + result = execute_command( + qe_collection_multi, + {"insert": qe_collection_multi.name, "documents": [{"_id": 1, "dob": "2000-01-01"}]}, + ) + assertFailureCode( + result, + DOCUMENT_VALIDATION_FAILURE_ERROR, + msg="insert should reject a plaintext value on any one of several encrypted fields.", + ) + + +# Property [Update Enforcement]: update applies the same validation as insert. +@pytest.mark.update +def test_encryption_update_rejects_plaintext(qe_collection): + """Test update rejects setting an encrypted field to a plaintext value.""" + qe_collection.insert_one({"_id": 1}) + result = execute_command( + qe_collection, + { + "update": qe_collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$set": {"ssn": "123-45-6789"}}}], + }, + ) + assertFailureCode( + result, + DOCUMENT_VALIDATION_FAILURE_ERROR, + msg="update should reject a plaintext value written to an encrypted path.", + ) + + +# Property [Delete Unaffected]: delete is unaffected by the collection's encryption schema. +@pytest.mark.delete +def test_encryption_delete_succeeds(qe_collection): + """Test delete succeeds for a document with an absent encrypted field.""" + qe_collection.insert_one({"_id": 1}) + result = execute_command( + qe_collection, {"delete": qe_collection.name, "deletes": [{"q": {"_id": 1}, "limit": 1}]} + ) + assertSuccessPartial( + result, + {"ok": 1.0, "n": 1}, + msg="delete should succeed on a Queryable Encryption collection.", + ) diff --git a/documentdb_tests/compatibility/tests/system/security/encryption/test_encryption_edge_cases.py b/documentdb_tests/compatibility/tests/system/security/encryption/test_encryption_edge_cases.py new file mode 100644 index 000000000..25fe20575 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/security/encryption/test_encryption_edge_cases.py @@ -0,0 +1,112 @@ +"""Tests for encryption edge cases: nested paths, value size, and multiplicity. + +Verifies that no value shape other than absence can be written to an encrypted +path without a client FLE driver, regardless of nesting, size, or document +count. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.framework.assertions import assertFailureCode, assertSuccessPartial +from documentdb_tests.framework.error_codes import DOCUMENT_VALIDATION_FAILURE_ERROR +from documentdb_tests.framework.executor import execute_command + +pytestmark = pytest.mark.requires(queryable_encryption=True) + + +# Property [Nested Path Independence]: a nested encrypted path is unaffected by +# insert as long as it is absent, regardless of whether its parent object is present. +@pytest.mark.insert +def test_encryption_insert_nested_path_missing_parent_succeeds(qe_collection_nested): + """Test insert succeeds when the parent of a nested encrypted path is absent.""" + result = execute_command( + qe_collection_nested, {"insert": qe_collection_nested.name, "documents": [{"_id": 1}]} + ) + assertSuccessPartial( + result, + {"ok": 1.0, "n": 1}, + msg="insert should succeed when the parent object of a nested encrypted path is absent.", + ) + + +@pytest.mark.insert +def test_encryption_insert_nested_path_parent_present_child_absent_succeeds(qe_collection_nested): + """Test insert succeeds when the parent object is present but the encrypted child is absent.""" + result = execute_command( + qe_collection_nested, + {"insert": qe_collection_nested.name, "documents": [{"_id": 1, "address": {"city": "x"}}]}, + ) + assertSuccessPartial( + result, + {"ok": 1.0, "n": 1}, + msg="insert should succeed when a nested encrypted field is absent" + " even if its parent object is present.", + ) + + +@pytest.mark.insert +def test_encryption_insert_nested_path_plaintext_rejected(qe_collection_nested): + """Test insert rejects a plaintext value at a nested encrypted path.""" + result = execute_command( + qe_collection_nested, + { + "insert": qe_collection_nested.name, + "documents": [{"_id": 1, "address": {"ssn": "123-45-6789"}}], + }, + ) + assertFailureCode( + result, + DOCUMENT_VALIDATION_FAILURE_ERROR, + msg="insert should reject a plaintext value at a nested encrypted path.", + ) + + +# Property [Validation Ignores Size]: a plaintext value at an encrypted path is +# rejected regardless of its size. +@pytest.mark.insert +def test_encryption_insert_large_plaintext_value_rejected(qe_collection): + """Test insert rejects a large plaintext value at an encrypted path.""" + result = execute_command( + qe_collection, + {"insert": qe_collection.name, "documents": [{"_id": 1, "ssn": "x" * 4_000}]}, + ) + assertFailureCode( + result, + DOCUMENT_VALIDATION_FAILURE_ERROR, + msg="insert should reject an oversized plaintext value at an encrypted path" + " just as it would a small one.", + ) + + +# Property [Absence Has No Multiplicity Constraint]: several documents may each omit +# the same encrypted field in one insert; equality does not impose uniqueness on absence. +@pytest.mark.insert +def test_encryption_insert_multiple_documents_all_missing_field(qe_collection): + """Test a batch insert succeeds when every document omits the same encrypted field.""" + result = execute_command( + qe_collection, + {"insert": qe_collection.name, "documents": [{"_id": 1}, {"_id": 2}]}, + ) + assertSuccessPartial( + result, + {"ok": 1.0, "n": 2}, + msg="a batch insert should succeed when every document omits the same encrypted field.", + ) + + +# Property [Explain Compatibility]: explain runs normally against a +# Queryable Encryption collection. +@pytest.mark.find +def test_encryption_explain_find_on_encrypted_collection(qe_collection): + """Test explain succeeds for a find against a Queryable Encryption collection.""" + result = execute_command( + qe_collection, + {"explain": {"find": qe_collection.name, "filter": {"ssn": "x"}}}, + ) + assertSuccessPartial( + result, + {"ok": 1.0}, + msg="explain should succeed for a find against a Queryable Encryption collection.", + ) diff --git a/documentdb_tests/compatibility/tests/system/security/encryption/test_encryption_query.py b/documentdb_tests/compatibility/tests/system/security/encryption/test_encryption_query.py new file mode 100644 index 000000000..0e8bc9bfb --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/security/encryption/test_encryption_query.py @@ -0,0 +1,92 @@ +"""Tests for query and aggregation behavior against Queryable Encryption collections. + +Verifies that a raw filter against an encrypted path, including a shape a real +client would never send such as a range comparison on an equality-only field, is +evaluated as an ordinary filter and never raises an encryption-specific error. +This repo has no client-side FLE driver, so query rewriting never happens. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_command + +pytestmark = pytest.mark.requires(queryable_encryption=True) + + +@pytest.fixture() +def qe_collection_seeded(qe_collection): + """A qe_collection populated with two documents that both omit the encrypted field.""" + qe_collection.insert_many([{"_id": 1, "name": "a"}, {"_id": 2, "name": "b"}]) + return qe_collection + + +@pytest.mark.find +def test_encryption_find_equality_filter_returns_empty(qe_collection_seeded): + """Test an equality filter on an encrypted field returns no matches, not an error.""" + result = execute_command( + qe_collection_seeded, {"find": qe_collection_seeded.name, "filter": {"ssn": "123-45-6789"}} + ) + assertResult( + result, + expected=[], + msg="an equality filter on an encrypted field should return no matches" + " when no document has that field set.", + ) + + +@pytest.mark.find +def test_encryption_find_range_operator_on_equality_field_no_error(qe_collection_seeded): + """Test a range operator on an equality-only encrypted field executes without error.""" + result = execute_command( + qe_collection_seeded, {"find": qe_collection_seeded.name, "filter": {"ssn": {"$gt": "a"}}} + ) + assertResult( + result, + expected=[], + msg="a range filter on an equality-only encrypted field should execute as an" + " ordinary filter rather than raise a query-type error, since raw commands" + " bypass client-side query rewriting.", + ) + + +@pytest.mark.find +def test_encryption_find_exists_false_matches_absent_field(qe_collection_seeded): + """Test $exists:false on an encrypted field matches documents where it is absent.""" + result = execute_command( + qe_collection_seeded, + { + "find": qe_collection_seeded.name, + "filter": {"ssn": {"$exists": False}}, + "sort": {"_id": 1}, + }, + ) + assertResult( + result, + expected=[{"_id": 1, "name": "a"}, {"_id": 2, "name": "b"}], + msg="$exists:false on an encrypted field should match documents where the field is absent.", + ) + + +@pytest.mark.aggregate +def test_encryption_aggregate_match_expr_on_encrypted_field(qe_collection_seeded): + """Test $match+$expr referencing an encrypted field in aggregation.""" + result = execute_command( + qe_collection_seeded, + { + "aggregate": qe_collection_seeded.name, + "pipeline": [ + {"$match": {"$expr": {"$eq": [{"$type": "$ssn"}, "missing"]}}}, + {"$project": {"_id": 1}}, + {"$sort": {"_id": 1}}, + ], + "cursor": {}, + }, + ) + assertResult( + result, + expected=[{"_id": 1}, {"_id": 2}], + msg="$match+$expr referencing an encrypted field should evaluate normally in aggregation.", + ) diff --git a/documentdb_tests/compatibility/tests/system/security/encryption/utils/__init__.py b/documentdb_tests/compatibility/tests/system/security/encryption/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/system/security/encryption/utils/qe_collections.py b/documentdb_tests/compatibility/tests/system/security/encryption/utils/qe_collections.py new file mode 100644 index 000000000..c9ad3b1a3 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/security/encryption/utils/qe_collections.py @@ -0,0 +1,64 @@ +"""Shared Queryable Encryption collection fixtures for tests in this directory. + +Creating a QE collection needs a per-field keyId, and each test file here needs +a differently-shaped one. The fixtures here are the single source of truth; +this directory's conftest re-exports them so every test file picks them up. +""" + +from __future__ import annotations + +from typing import Any +from uuid import uuid4 + +import pytest +from bson import Binary +from pymongo.collection import Collection + + +def _create_qe_collection( + collection: Collection, name: str, fields: list[dict[str, Any]] +) -> Collection: + """Create and return a Queryable Encryption collection with the given fields.""" + db = collection.database + resolved_fields = [{**field, "keyId": Binary(uuid4().bytes, 4)} for field in fields] + db.command("create", name, encryptedFields={"fields": resolved_fields}) + return db[name] + + +@pytest.fixture() +def qe_collection(collection): + """A Queryable Encryption collection with ssn as encrypted field.""" + qe = _create_qe_collection( + collection, + f"{collection.name}_qe", + [{"path": "ssn", "bsonType": "string", "queries": {"queryType": "equality"}}], + ) + yield qe + qe.database.drop_collection(qe.name) + + +@pytest.fixture() +def qe_collection_multi(collection): + """A Queryable Encryption collection with two independent encrypted fields.""" + qe = _create_qe_collection( + collection, + f"{collection.name}_qe_multi", + [ + {"path": "ssn", "bsonType": "string"}, + {"path": "dob", "bsonType": "string"}, + ], + ) + yield qe + qe.database.drop_collection(qe.name) + + +@pytest.fixture() +def qe_collection_nested(collection): + """A Queryable Encryption collection with address.ssn as a nested encrypted path.""" + qe = _create_qe_collection( + collection, + f"{collection.name}_qe_nested", + [{"path": "address.ssn", "bsonType": "string"}], + ) + yield qe + qe.database.drop_collection(qe.name) From 6074ca37572cee98569542346109ff056cc1196b Mon Sep 17 00:00:00 2001 From: "Alina (Xi) Li" <96995091+alinaliBQ@users.noreply.github.com> Date: Mon, 6 Jul 2026 11:17:51 -0700 Subject: [PATCH 02/35] Add `killSessions` command tests (#593) Signed-off-by: Alina (Xi) Li Signed-off-by: RyanGarfinkel <113050972+RyanGarfinkel@users.noreply.github.com> --- .../commands/killSessions/__init__.py | 0 .../test_killSessions_acceptance.py | 236 ++++++++++++++++++ .../killSessions/test_killSessions_core.py | 213 ++++++++++++++++ .../test_killSessions_field_type_error.py | 155 ++++++++++++ .../test_killSessions_maxtimems_error.py | 143 +++++++++++ .../test_killSessions_options_error.py | 103 ++++++++ .../test_killSessions_readconcern_error.py | 205 +++++++++++++++ .../test_killSessions_sessions_type_error.py | 199 +++++++++++++++ 8 files changed, 1254 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/killSessions/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/killSessions/test_killSessions_acceptance.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/killSessions/test_killSessions_core.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/killSessions/test_killSessions_field_type_error.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/killSessions/test_killSessions_maxtimems_error.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/killSessions/test_killSessions_options_error.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/killSessions/test_killSessions_readconcern_error.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/killSessions/test_killSessions_sessions_type_error.py diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/killSessions/__init__.py b/documentdb_tests/compatibility/tests/core/sessions/commands/killSessions/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/killSessions/test_killSessions_acceptance.py b/documentdb_tests/compatibility/tests/core/sessions/commands/killSessions/test_killSessions_acceptance.py new file mode 100644 index 000000000..51c88d5f3 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/killSessions/test_killSessions_acceptance.py @@ -0,0 +1,236 @@ +"""Tests for killSessions parameter acceptance.""" + +from __future__ import annotations + +import uuid + +import pytest +from bson import Binary, Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.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.test_constants import ( + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ZERO, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_ZERO, + INT32_MAX, + INT64_ZERO, +) + +pytestmark = pytest.mark.no_parallel + +# Property [maxTimeMS Acceptance]: maxTimeMS accepts values at both +# boundaries of the valid range across all numeric types. +KILLSESSIONS_MAXTIMEMS_ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"maxtimems_{tid}", + command=lambda ctx, v=val: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "maxTimeMS": v, + }, + expected={"ok": 1.0}, + msg=f"killSessions should accept {tid} maxTimeMS", + ) + for tid, val in [ + # Lower boundary (0) in all representations. + ("int32_zero", 0), + ("int64_zero", INT64_ZERO), + ("double_zero", DOUBLE_ZERO), + ("double_negative_zero", DOUBLE_NEGATIVE_ZERO), + ("decimal128_zero", DECIMAL128_ZERO), + ("decimal128_negative_zero", DECIMAL128_NEGATIVE_ZERO), + # Upper boundary (INT32_MAX) in all representations. + ("int32_max", INT32_MAX), + ("int64_max", Int64(INT32_MAX)), + ("double_max", float(INT32_MAX)), + ("decimal128_max", Decimal128(str(INT32_MAX))), + # Null treated as omitted. + ("null", None), + ] +] + +# Property [writeConcern Null Acceptance]: null writeConcern is treated +# as omitted and accepted. +KILLSESSIONS_WRITECONCERN_ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "writeconcern_null", + command=lambda ctx: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "writeConcern": None, + }, + expected={"ok": 1.0}, + msg="killSessions should accept null writeConcern", + ), +] + +# Property [Unrecognized Fields]: unknown fields in the command document +# are silently ignored. +KILLSESSIONS_UNRECOGNIZED_FIELD_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "unrecognized_single", + command=lambda ctx: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "unknownField": 1, + }, + expected={"ok": 1.0}, + msg="killSessions should ignore a single unknown field", + ), + CommandTestCase( + "unrecognized_multiple", + command=lambda ctx: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "foo": 1, + "bar": 2, + }, + expected={"ok": 1.0}, + msg="killSessions should ignore multiple unknown fields", + ), + CommandTestCase( + "unrecognized_dollar_prefix", + command=lambda ctx: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "$unknown": 1, + }, + expected={"ok": 1.0}, + msg="killSessions should ignore dollar-prefixed unknown field", + ), + CommandTestCase( + "unrecognized_other_command", + command=lambda ctx: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "query": {"x": 1}, + }, + expected={"ok": 1.0}, + msg="killSessions should ignore field from another command", + ), +] + +# Property [readConcern Acceptance]: readConcern with level "local", null, +# empty document, or null level is accepted without error. +KILLSESSIONS_READCONCERN_ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "readconcern_local", + command=lambda ctx: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "readConcern": {"level": "local"}, + }, + expected={"ok": 1.0}, + msg="killSessions should accept readConcern with level local", + ), + CommandTestCase( + "readconcern_null", + command=lambda ctx: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "readConcern": None, + }, + expected={"ok": 1.0}, + msg="killSessions should accept null readConcern", + ), + CommandTestCase( + "readconcern_empty_doc", + command=lambda ctx: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "readConcern": {}, + }, + expected={"ok": 1.0}, + msg="killSessions should accept empty readConcern document", + ), + CommandTestCase( + "readconcern_level_null", + command=lambda ctx: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "readConcern": {"level": None}, + }, + expected={"ok": 1.0}, + msg="killSessions should accept readConcern with null level", + ), +] + +# Property [Null Array Element Acceptance]: null elements in the array +# are silently accepted without error. +KILLSESSIONS_NULL_ELEMENT_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "element_null_accepted", + command=lambda ctx: {"killSessions": [None]}, + expected={"ok": 1.0}, + msg="killSessions should accept null array element", + ), + CommandTestCase( + "element_null_after_valid", + command=lambda ctx: {"killSessions": [{"id": Binary(uuid.uuid4().bytes, 4)}, None]}, + expected={"ok": 1.0}, + msg="killSessions should accept null element after valid element", + ), +] + +# Property [Stable API V1 Acceptance]: killSessions succeeds with +# apiVersion "1" and non-strict API parameters. +KILLSESSIONS_STABLE_API_ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "api_version_1", + command=lambda ctx: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "apiVersion": "1", + }, + expected={"ok": 1.0}, + msg="killSessions should succeed with apiVersion 1", + ), + CommandTestCase( + "api_version_1_strict_false", + command=lambda ctx: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "apiVersion": "1", + "apiStrict": False, + }, + expected={"ok": 1.0}, + msg="killSessions should succeed with apiVersion 1 and apiStrict false", + ), + CommandTestCase( + "api_version_1_deprecation_true", + command=lambda ctx: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "apiVersion": "1", + "apiDeprecationErrors": True, + }, + expected={"ok": 1.0}, + msg="killSessions should succeed with apiVersion 1 and apiDeprecationErrors true", + ), + CommandTestCase( + "api_version_1_deprecation_false", + command=lambda ctx: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "apiVersion": "1", + "apiDeprecationErrors": False, + }, + expected={"ok": 1.0}, + msg="killSessions should succeed with apiVersion 1 and apiDeprecationErrors false", + ), +] + +KILLSESSIONS_ACCEPTANCE_TESTS: list[CommandTestCase] = ( + KILLSESSIONS_MAXTIMEMS_ACCEPTANCE_TESTS + + KILLSESSIONS_WRITECONCERN_ACCEPTANCE_TESTS + + KILLSESSIONS_UNRECOGNIZED_FIELD_TESTS + + KILLSESSIONS_READCONCERN_ACCEPTANCE_TESTS + + KILLSESSIONS_NULL_ELEMENT_TESTS + + KILLSESSIONS_STABLE_API_ACCEPTANCE_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(KILLSESSIONS_ACCEPTANCE_TESTS)) +def test_killSessions_acceptance(collection, test): + """Test killSessions parameter acceptance.""" + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/killSessions/test_killSessions_core.py b/documentdb_tests/compatibility/tests/core/sessions/commands/killSessions/test_killSessions_core.py new file mode 100644 index 000000000..3c26b5c75 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/killSessions/test_killSessions_core.py @@ -0,0 +1,213 @@ +"""Tests for killSessions core behavior and comment acceptance.""" + +from __future__ import annotations + +import uuid +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import CURSOR_NOT_FOUND_ERROR +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = pytest.mark.no_parallel + +# Property [Random UUID]: killSessions with a non-matching UUID succeeds +# silently without error. +KILLSESSIONS_RANDOM_UUID_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "random_uuid_single", + command=lambda ctx: {"killSessions": [{"id": Binary(uuid.uuid4().bytes, 4)}]}, + expected={"ok": 1.0}, + msg="killSessions should succeed with a random non-matching UUID", + ), + CommandTestCase( + "random_uuid_two", + command=lambda ctx: { + "killSessions": [ + {"id": Binary(uuid.uuid4().bytes, 4)}, + {"id": Binary(uuid.uuid4().bytes, 4)}, + ] + }, + expected={"ok": 1.0}, + msg="killSessions should succeed with two random UUIDs", + ), + CommandTestCase( + "random_uuid_five", + command=lambda ctx: { + "killSessions": [{"id": Binary(uuid.uuid4().bytes, 4)} for _ in range(5)] + }, + expected={"ok": 1.0}, + msg="killSessions should succeed with five random UUIDs", + ), +] + +# Property [Duplicate UUIDs]: duplicate session IDs in the array are +# accepted without error. +KILLSESSIONS_DUPLICATE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "duplicate_uuids", + command=lambda ctx: { + "killSessions": [ + {"id": Binary(b"\xde\xad" * 8, subtype=4)}, + {"id": Binary(b"\xde\xad" * 8, subtype=4)}, + ] + }, + expected={"ok": 1.0}, + msg="killSessions should accept duplicate UUIDs without error", + ), +] + +# Property [Large Array]: killSessions handles a large array of session +# identifiers without error. +KILLSESSIONS_LARGE_ARRAY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "large_array_100", + command=lambda ctx: { + "killSessions": [{"id": Binary(uuid.uuid4().bytes, 4)} for _ in range(100)] + }, + expected={"ok": 1.0}, + msg="killSessions should handle 100 session identifiers", + ), +] + +# Property [comment Field Universal Type Acceptance]: all BSON types +# representable by pymongo are accepted for the comment field without +# restriction. +KILLSESSIONS_COMMENT_TYPE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"comment_{tid}", + command=lambda ctx, v=val: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "comment": v, + }, + expected={"ok": 1.0}, + msg=f"killSessions should accept {tid} comment", + ) + for tid, val in [ + ("string", "test comment"), + ("string_empty", ""), + ("int32", 42), + ("int64", Int64(123)), + ("double", 3.14), + ("decimal128", Decimal128("1.5")), + ("bool_true", True), + ("bool_false", False), + ("null", None), + ("object", {"key": "value"}), + ("object_empty", {}), + ("array", [1, "two", 3.0]), + ("array_empty", []), + ("objectid", ObjectId()), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("timestamp", Timestamp(1, 1)), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Database Context]: killSessions succeeds when run on +# the admin database (not just the default test database). +KILLSESSIONS_ADMIN_DB_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "admin_database", + command=lambda ctx: {"killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}]}, + expected={"ok": 1.0}, + msg="killSessions should succeed on admin database", + ), +] + +KILLSESSIONS_CORE_TESTS: list[CommandTestCase] = ( + KILLSESSIONS_RANDOM_UUID_TESTS + + KILLSESSIONS_DUPLICATE_TESTS + + KILLSESSIONS_LARGE_ARRAY_TESTS + + KILLSESSIONS_COMMENT_TYPE_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(KILLSESSIONS_CORE_TESTS)) +def test_killSessions_core(collection, test): + """Test killSessions core behavior.""" + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + msg=test.msg, + raw_res=True, + ) + + +@pytest.mark.parametrize("test", pytest_params(KILLSESSIONS_ADMIN_DB_TESTS)) +def test_killSessions_admin_db(collection, test): + """Test killSessions on admin database.""" + ctx = CommandContext.from_collection(collection) + result = execute_admin_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + msg=test.msg, + raw_res=True, + ) + + +def test_killSessions_real_session(collection): + """Test killSessions kills a real active session. + + Opens a cursor under the session, kills the session, then verifies + that getMore fails with CursorNotFound — proving the kill closed + the cursor. + """ + client = collection.database.client + db = collection.database + # Use a context manager so the session is always ended, even if an + # assertion or command fails partway through. + with client.start_session() as session: + lsid = session.session_id["id"] + + # Insert enough docs to require a getMore. + collection.insert_many([{"_id": i} for i in range(10)], session=session) + + # Open a cursor with a small batch so the first batch doesn't exhaust it. + find_result = db.command( + {"find": collection.name, "batchSize": 2}, + session=session, + ) + cursor_id = find_result["cursor"]["id"] + + # Kill the session. + execute_command(collection, {"killSessions": [{"id": lsid}]}) + + # getMore on the killed session's cursor should fail with CursorNotFound, + # proving the kill closed the cursor. + get_more_result = execute_command( + collection, + {"getMore": cursor_id, "collection": collection.name}, + session=session, + ) + assertResult( + get_more_result, + error_code=CURSOR_NOT_FOUND_ERROR, + msg="getMore should fail after session is killed", + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/killSessions/test_killSessions_field_type_error.py b/documentdb_tests/compatibility/tests/core/sessions/commands/killSessions/test_killSessions_field_type_error.py new file mode 100644 index 000000000..ac81b70a4 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/killSessions/test_killSessions_field_type_error.py @@ -0,0 +1,155 @@ +"""Tests for killSessions command field type and Stable API errors.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + API_STRICT_ERROR, + API_VERSION_ERROR, + API_VERSION_REQUIRED_ERROR, + MISSING_FIELD_ERROR, + TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = pytest.mark.no_parallel + +# Property [Command Field Type Rejection]: the killSessions command field +# expects an array. All non-array BSON types are rejected. +KILLSESSIONS_FIELD_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"field_type_{tid}", + command=lambda ctx, v=val: {"killSessions": v}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"killSessions should reject {tid} as command field value", + ) + for tid, val in [ + ("int32", 1), + ("int32_zero", 0), + ("int32_negative", -1), + ("int64", Int64(1)), + ("double", 1.0), + ("double_zero", 0.0), + ("double_negative", -1.0), + ("decimal128", Decimal128("1")), + ("bool_true", True), + ("bool_false", False), + ("string", "test"), + ("string_empty", ""), + ("object_empty", {}), + ("object", {"key": "value"}), + ("binary", Binary(b"\x00\x01\x02")), + ("objectid", ObjectId()), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("regex", Regex(".*", "i")), + ("timestamp", Timestamp(1, 1)), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("double_nan", float("nan")), + ("double_inf", float("inf")), + ] +] + [ + # Null produces a missing field error rather than a type mismatch. + CommandTestCase( + "field_type_null", + command=lambda ctx: {"killSessions": None}, + error_code=MISSING_FIELD_ERROR, + msg="killSessions should reject null as command field value", + ), +] + +# Property [Stable API V1 Rejection]: killSessions is NOT in API +# Version 1, so apiStrict true rejects it. +KILLSESSIONS_STABLE_API_STRICT_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "api_strict_true", + command=lambda ctx: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "apiVersion": "1", + "apiStrict": True, + }, + error_code=API_STRICT_ERROR, + msg="killSessions should be rejected with apiStrict true", + ), +] + +# Property [API Version Errors]: invalid apiVersion values and +# missing apiVersion with other API parameters are rejected. +KILLSESSIONS_API_VERSION_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "api_version_2", + command=lambda ctx: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "apiVersion": "2", + }, + error_code=API_VERSION_ERROR, + msg="killSessions should reject apiVersion 2", + ), + CommandTestCase( + "api_version_empty", + command=lambda ctx: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "apiVersion": "", + }, + error_code=API_VERSION_ERROR, + msg="killSessions should reject empty apiVersion", + ), + CommandTestCase( + "api_strict_without_version", + command=lambda ctx: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "apiStrict": True, + }, + error_code=API_VERSION_REQUIRED_ERROR, + msg="killSessions should reject apiStrict without apiVersion", + ), + CommandTestCase( + "api_deprecation_without_version", + command=lambda ctx: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "apiDeprecationErrors": True, + }, + error_code=API_VERSION_REQUIRED_ERROR, + msg="killSessions should reject apiDeprecationErrors without apiVersion", + ), +] + +KILLSESSIONS_FIELD_TYPE_AND_API_ERROR_TESTS: list[CommandTestCase] = ( + KILLSESSIONS_FIELD_TYPE_ERROR_TESTS + + KILLSESSIONS_STABLE_API_STRICT_ERROR_TESTS + + KILLSESSIONS_API_VERSION_ERROR_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(KILLSESSIONS_FIELD_TYPE_AND_API_ERROR_TESTS)) +def test_killSessions_field_type_error(collection, test): + """Test killSessions command field type and Stable API errors.""" + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/killSessions/test_killSessions_maxtimems_error.py b/documentdb_tests/compatibility/tests/core/sessions/commands/killSessions/test_killSessions_maxtimems_error.py new file mode 100644 index 000000000..593e90b8b --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/killSessions/test_killSessions_maxtimems_error.py @@ -0,0 +1,143 @@ +"""Tests for killSessions maxTimeMS field errors.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + FAILED_TO_PARSE_ERROR, + TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_NAN, + DECIMAL128_ONE_AND_HALF, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + FLOAT_NEGATIVE_NAN, + INT32_OVERFLOW, +) + +pytestmark = pytest.mark.no_parallel + +# Property [maxTimeMS Type Rejection]: all non-numeric, non-null BSON +# types for maxTimeMS are rejected. +KILLSESSIONS_MAXTIMEMS_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"maxtimems_type_{tid}", + command=lambda ctx, v=val: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "maxTimeMS": v, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"killSessions should reject {tid} for maxTimeMS", + ) + for tid, val in [ + ("bool_true", True), + ("bool_false", False), + ("string", "1000"), + ("object", {"a": 1}), + ("array", [1, 2]), + ("objectid", ObjectId()), + ("binary", Binary(b"\x01")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("regex", Regex(".*")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [maxTimeMS Value Rejection]: values outside the valid range +# or not representable as whole integers are rejected. +KILLSESSIONS_MAXTIMEMS_VALUE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"maxtimems_value_{tid}", + command=lambda ctx, v=val: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "maxTimeMS": v, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg=f"killSessions should reject {tid} maxTimeMS", + ) + for tid, val in [ + # Fractional values. + ("fractional_double", 1.5), + ("fractional_decimal128", DECIMAL128_ONE_AND_HALF), + # Double NaN and Infinity. + ("double_nan", FLOAT_NAN), + ("double_negative_nan", FLOAT_NEGATIVE_NAN), + ("double_positive_infinity", FLOAT_INFINITY), + ("double_negative_infinity", FLOAT_NEGATIVE_INFINITY), + # Decimal128 NaN and Infinity. + ("decimal128_nan", DECIMAL128_NAN), + ("decimal128_negative_nan", DECIMAL128_NEGATIVE_NAN), + ("decimal128_positive_infinity", DECIMAL128_INFINITY), + ("decimal128_negative_infinity", DECIMAL128_NEGATIVE_INFINITY), + ] +] + [ + CommandTestCase( + f"maxtimems_value_{tid}", + command=lambda ctx, v=val: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "maxTimeMS": v, + }, + error_code=BAD_VALUE_ERROR, + msg=f"killSessions should reject {tid} maxTimeMS", + ) + for tid, val in [ + # Below lower boundary. + ("int32_negative", -1), + ("int64_negative", Int64(-1)), + ("double_negative", -1.0), + ("decimal128_negative", Decimal128("-1")), + # Above upper boundary. + ("int32_overflow", INT32_OVERFLOW), + ("int64_overflow", Int64(INT32_OVERFLOW)), + ("double_overflow", float(INT32_OVERFLOW)), + ("decimal128_overflow", Decimal128(str(INT32_OVERFLOW))), + ] +] + +KILLSESSIONS_MAXTIMEMS_ERROR_TESTS: list[CommandTestCase] = ( + KILLSESSIONS_MAXTIMEMS_TYPE_ERROR_TESTS + KILLSESSIONS_MAXTIMEMS_VALUE_ERROR_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(KILLSESSIONS_MAXTIMEMS_ERROR_TESTS)) +def test_killSessions_maxtimems_error(collection, test): + """Test killSessions maxTimeMS field errors.""" + 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/compatibility/tests/core/sessions/commands/killSessions/test_killSessions_options_error.py b/documentdb_tests/compatibility/tests/core/sessions/commands/killSessions/test_killSessions_options_error.py new file mode 100644 index 000000000..e68411bbe --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/killSessions/test_killSessions_options_error.py @@ -0,0 +1,103 @@ +"""Tests for killSessions writeConcern field errors.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + INVALID_OPTIONS_ERROR, + TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = pytest.mark.no_parallel + +# Property [writeConcern Type Rejection]: all non-document, non-null BSON +# types for the writeConcern field are rejected. +KILLSESSIONS_WRITECONCERN_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"writeconcern_type_{tid}", + command=lambda ctx, v=val: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "writeConcern": v, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"killSessions should reject {tid} writeConcern", + ) + for tid, val in [ + ("string", "majority"), + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.0), + ("decimal128", Decimal128("1")), + ("bool_true", True), + ("bool_false", False), + ("array", []), + ("array_nonempty", [1, 2]), + ("binary", Binary(b"data")), + ("objectid", ObjectId()), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("regex", Regex(".*")), + ("timestamp", Timestamp(1, 1)), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [writeConcern Document Rejection]: document values for +# writeConcern are rejected because the command does not support it. +KILLSESSIONS_WRITECONCERN_DOC_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"writeconcern_doc_{tid}", + command=lambda ctx, v=val: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "writeConcern": v, + }, + error_code=INVALID_OPTIONS_ERROR, + msg=f"killSessions should reject writeConcern {tid}", + ) + for tid, val in [ + ("empty", {}), + ("w_1", {"w": 1}), + ("w_majority", {"w": "majority"}), + ("j_true", {"j": True}), + ] +] + +KILLSESSIONS_OPTIONS_ERROR_TESTS: list[CommandTestCase] = ( + KILLSESSIONS_WRITECONCERN_TYPE_ERROR_TESTS + KILLSESSIONS_WRITECONCERN_DOC_ERROR_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(KILLSESSIONS_OPTIONS_ERROR_TESTS)) +def test_killSessions_options_error(collection, test): + """Test killSessions writeConcern field errors.""" + 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/compatibility/tests/core/sessions/commands/killSessions/test_killSessions_readconcern_error.py b/documentdb_tests/compatibility/tests/core/sessions/commands/killSessions/test_killSessions_readconcern_error.py new file mode 100644 index 000000000..6d646baa3 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/killSessions/test_killSessions_readconcern_error.py @@ -0,0 +1,205 @@ +"""Tests for killSessions readConcern field errors.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + ILLEGAL_OPERATION_ERROR, + INVALID_OPTIONS_ERROR, + TYPE_MISMATCH_ERROR, + UNRECOGNIZED_COMMAND_FIELD_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = pytest.mark.no_parallel + +# Property [readConcern Level Rejection]: readConcern with levels other +# than "local" is rejected. +KILLSESSIONS_READCONCERN_LEVEL_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "readconcern_majority", + command=lambda ctx: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "readConcern": {"level": "majority"}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="killSessions should reject readConcern level majority", + ), + CommandTestCase( + "readconcern_available", + command=lambda ctx: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "readConcern": {"level": "available"}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="killSessions should reject readConcern level available", + ), + CommandTestCase( + "readconcern_linearizable", + command=lambda ctx: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "readConcern": {"level": "linearizable"}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="killSessions should reject readConcern level linearizable", + ), + CommandTestCase( + "readconcern_snapshot", + command=lambda ctx: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "readConcern": {"level": "snapshot"}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="killSessions should reject readConcern level snapshot", + ), + CommandTestCase( + "readconcern_level_invalid_name", + command=lambda ctx: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "readConcern": {"level": "invalid"}, + }, + error_code=BAD_VALUE_ERROR, + msg="killSessions should reject unrecognized readConcern level name", + ), + CommandTestCase( + "readconcern_level_empty_string", + command=lambda ctx: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "readConcern": {"level": ""}, + }, + error_code=BAD_VALUE_ERROR, + msg="killSessions should reject empty string readConcern level", + ), +] + +# Property [readConcern Type Rejection]: all non-document, non-null BSON +# types for the readConcern field are rejected. +KILLSESSIONS_READCONCERN_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"readconcern_type_{tid}", + command=lambda ctx, v=val: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "readConcern": v, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"killSessions should reject {tid} readConcern", + ) + for tid, val in [ + ("string", "local"), + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", Decimal128("1")), + ("bool_true", True), + ("bool_false", False), + ("array", [1, 2]), + ("binary", Binary(b"data")), + ("objectid", ObjectId()), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("regex", Regex(".*")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [readConcern Subfield Rejection]: invalid subfields, unknown +# fields, and wrong types within the readConcern document are rejected. +KILLSESSIONS_READCONCERN_SUBFIELD_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "readconcern_unknown_subfield", + command=lambda ctx: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "readConcern": {"bogusField": 1}, + }, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="killSessions should reject unknown subfield in readConcern", + ), + CommandTestCase( + "readconcern_afterclustertime_valid_timestamp", + command=lambda ctx: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "readConcern": {"afterClusterTime": Timestamp(1, 1)}, + }, + error_code=ILLEGAL_OPERATION_ERROR, + # afterClusterTime is rejected only where replication-dependent read + # concern is unavailable (standalone); a replica set accepts it. + marks=(pytest.mark.requires(cluster_read_concern=False),), + msg="killSessions should reject afterClusterTime in readConcern on a standalone server", + ), +] + +# Property [readConcern Level Type Rejection]: all non-string, non-null +# types for the readConcern level field are rejected. +KILLSESSIONS_READCONCERN_LEVEL_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"readconcern_level_type_{tid}", + command=lambda ctx, v=val: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "readConcern": {"level": v}, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"killSessions should reject {tid} type for readConcern level", + ) + for tid, val in [ + ("int32", 42), + ("int64", Int64(1)), + ("double", 1.0), + ("decimal128", Decimal128("1")), + ("bool_true", True), + ("bool_false", False), + ("array", ["local"]), + ("document", {"a": 1}), + ("binary", Binary(b"local")), + ("objectid", ObjectId()), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("regex", Regex("local")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +KILLSESSIONS_READCONCERN_ERROR_TESTS: list[CommandTestCase] = ( + KILLSESSIONS_READCONCERN_LEVEL_ERROR_TESTS + + KILLSESSIONS_READCONCERN_TYPE_ERROR_TESTS + + KILLSESSIONS_READCONCERN_SUBFIELD_ERROR_TESTS + + KILLSESSIONS_READCONCERN_LEVEL_TYPE_ERROR_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(KILLSESSIONS_READCONCERN_ERROR_TESTS)) +def test_killSessions_readconcern_error(collection, test): + """Test killSessions readConcern field errors.""" + 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/compatibility/tests/core/sessions/commands/killSessions/test_killSessions_sessions_type_error.py b/documentdb_tests/compatibility/tests/core/sessions/commands/killSessions/test_killSessions_sessions_type_error.py new file mode 100644 index 000000000..6fdb6f678 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/killSessions/test_killSessions_sessions_type_error.py @@ -0,0 +1,199 @@ +"""Tests for killSessions array element and session ID type errors.""" + +from __future__ import annotations + +import uuid +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + INVALID_UUID_ERROR, + MISSING_FIELD_ERROR, + TYPE_MISMATCH_ERROR, + UNRECOGNIZED_COMMAND_FIELD_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = pytest.mark.no_parallel + +# Property [Non-Document Array Elements]: array elements that are not +# documents are rejected. +KILLSESSIONS_ELEMENT_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"element_type_{tid}", + command=lambda ctx, v=val: {"killSessions": [v]}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"killSessions should reject {tid} array element", + ) + for tid, val in [ + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.0), + ("decimal128", Decimal128("1")), + ("string", "string"), + ("bool_true", True), + ("bool_false", False), + ("array", []), + ("binary", Binary(b"\x00")), + ("objectid", ObjectId()), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("regex", Regex(".*")), + ("timestamp", Timestamp(1, 1)), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Invalid Document Elements]: documents missing the required +# id field or containing wrong field names are rejected. +KILLSESSIONS_INVALID_DOC_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "element_empty_doc", + command=lambda ctx: {"killSessions": [{}]}, + error_code=MISSING_FIELD_ERROR, + msg="killSessions should reject empty document element", + ), + CommandTestCase( + "element_wrong_field", + command=lambda ctx: {"killSessions": [{"notId": Binary(uuid.uuid4().bytes, 4)}]}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="killSessions should reject document with wrong field name", + ), + CommandTestCase( + "element_unrelated_fields", + command=lambda ctx: {"killSessions": [{"foo": "bar"}]}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="killSessions should reject document with unrelated fields", + ), +] + +# Property [Mixed Valid and Invalid Elements]: an invalid element after +# a valid one is still rejected. +KILLSESSIONS_MIXED_ELEMENT_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "mixed_valid_then_int", + command=lambda ctx: {"killSessions": [{"id": Binary(uuid.uuid4().bytes, 4)}, 1]}, + error_code=TYPE_MISMATCH_ERROR, + msg="killSessions should reject int element after valid element", + ), +] + +# Property [Session ID Type Rejection]: the id field must be a UUID +# (Binary subtype 4). All other types are rejected. +KILLSESSIONS_ID_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"id_type_{tid}", + command=lambda ctx, v=val: {"killSessions": [{"id": v}]}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"killSessions should reject {tid} as session id", + ) + for tid, val in [ + ("string", "not-a-uuid-string"), + ("int32", 12345), + ("int64", Int64(1)), + ("double", 1.0), + ("decimal128", Decimal128("1")), + ("bool_true", True), + ("bool_false", False), + ("array", []), + ("object", {}), + ("objectid", ObjectId()), + ("binary_subtype0", Binary(b"\xde\xad" * 8, subtype=0)), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("regex", Regex(".*")), + ("timestamp", Timestamp(1, 1)), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + [ + # Null id produces a missing field error rather than a type mismatch. + CommandTestCase( + "id_type_null", + command=lambda ctx: {"killSessions": [{"id": None}]}, + error_code=MISSING_FIELD_ERROR, + msg="killSessions should reject null as session id", + ), +] + +# Property [UUID Binary Size Rejection]: Binary subtype 4 values that are +# not exactly 16 bytes are rejected with INVALID_UUID_ERROR. +KILLSESSIONS_UUID_SIZE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "uuid_too_short", + command=lambda ctx: {"killSessions": [{"id": Binary(b"\x00" * 8, subtype=4)}]}, + error_code=INVALID_UUID_ERROR, + msg="killSessions should reject Binary subtype 4 with 8 bytes", + ), + CommandTestCase( + "uuid_too_long", + command=lambda ctx: {"killSessions": [{"id": Binary(b"\x00" * 32, subtype=4)}]}, + error_code=INVALID_UUID_ERROR, + msg="killSessions should reject Binary subtype 4 with 32 bytes", + ), +] + +# Property [Extra Fields in Session Identifier]: documents with extra +# fields alongside id are rejected. +KILLSESSIONS_EXTRA_FIELDS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "extra_field_alongside_id", + command=lambda ctx: {"killSessions": [{"id": Binary(uuid.uuid4().bytes, 4), "extra": 1}]}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="killSessions should reject extra fields alongside id", + ), +] + +# Property [Binary Subtype 3]: Binary subtype 3 (old UUID) is rejected +# for the id field. +KILLSESSIONS_BINARY_SUBTYPE3_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "id_binary_subtype3", + command=lambda ctx: {"killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=3)}]}, + error_code=TYPE_MISMATCH_ERROR, + msg="killSessions should reject Binary subtype 3 as session id", + ), +] + +KILLSESSIONS_SESSIONS_TYPE_ERROR_TESTS: list[CommandTestCase] = ( + KILLSESSIONS_ELEMENT_TYPE_ERROR_TESTS + + KILLSESSIONS_INVALID_DOC_ERROR_TESTS + + KILLSESSIONS_MIXED_ELEMENT_ERROR_TESTS + + KILLSESSIONS_ID_TYPE_ERROR_TESTS + + KILLSESSIONS_UUID_SIZE_ERROR_TESTS + + KILLSESSIONS_EXTRA_FIELDS_TESTS + + KILLSESSIONS_BINARY_SUBTYPE3_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(KILLSESSIONS_SESSIONS_TYPE_ERROR_TESTS)) +def test_killSessions_sessions_type_error(collection, test): + """Test killSessions array element and session ID type errors.""" + 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, + ) From 413f2da5d1e0ed6cf8b98d0b331d40a0bae8b119 Mon Sep 17 00:00:00 2001 From: Victor Tsang Date: Mon, 6 Jul 2026 14:03:21 -0700 Subject: [PATCH 03/35] Add update tests for pullAll (#602) Signed-off-by: Victor [C] Tsang Signed-off-by: RyanGarfinkel <113050972+RyanGarfinkel@users.noreply.github.com> --- .../test_operations_update_array_ops.py | 81 +++++++ .../operator/update/array/pullAll/__init__.py | 0 .../pullAll/test_pullAll_argument_handling.py | 61 +++++ .../test_pullAll_bson_type_validation.py | 107 +++++++++ .../pullAll/test_pullAll_core_behavior.py | 91 ++++++++ .../array/pullAll/test_pullAll_data_types.py | 216 ++++++++++++++++++ .../pullAll/test_pullAll_nested_fields.py | 90 ++++++++ .../test_pullAll_update_integration.py | 131 +++++++++++ .../pullAll/test_pullAll_value_matching.py | 115 ++++++++++ 9 files changed, 892 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_argument_handling.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_bson_type_validation.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_core_behavior.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_data_types.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_nested_fields.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_update_integration.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_value_matching.py diff --git a/documentdb_tests/compatibility/tests/core/collation/command_level/operations/test_operations_update_array_ops.py b/documentdb_tests/compatibility/tests/core/collation/command_level/operations/test_operations_update_array_ops.py index e47e908e2..d18a75707 100644 --- a/documentdb_tests/compatibility/tests/core/collation/command_level/operations/test_operations_update_array_ops.py +++ b/documentdb_tests/compatibility/tests/core/collation/command_level/operations/test_operations_update_array_ops.py @@ -117,6 +117,54 @@ expected={"ok": 1.0, "n": 1, "nModified": 1}, msg="$pullAll with strength 2 should remove case-variant elements", ), + CommandTestCase( + "pullall_accent_insensitive", + docs=[{"_id": 1, "tags": ["cafe", "caf\u00e9", "tea"]}], + command=lambda ctx: { + "update": ctx.collection, + "updates": [ + { + "q": {"_id": 1}, + "u": {"$pullAll": {"tags": ["cafe"]}}, + "collation": {"locale": "en", "strength": 1}, + } + ], + }, + expected={"ok": 1.0, "n": 1, "nModified": 1}, + msg="$pullAll with strength 1 should remove accent-variant elements", + ), + CommandTestCase( + "pullall_strength3_no_case_match", + docs=[{"_id": 1, "tags": ["Apple", "apple", "banana"]}], + command=lambda ctx: { + "update": ctx.collection, + "updates": [ + { + "q": {"_id": 1}, + "u": {"$pullAll": {"tags": ["Apple"]}}, + "collation": {"locale": "en", "strength": 3}, + } + ], + }, + expected={"ok": 1.0, "n": 1, "nModified": 1}, + msg="$pullAll with strength 3 should not match case variants", + ), + CommandTestCase( + "pullall_numeric_ordering", + docs=[{"_id": 1, "vals": ["1", "2", "10", "20"]}], + command=lambda ctx: { + "update": ctx.collection, + "updates": [ + { + "q": {"_id": 1}, + "u": {"$pullAll": {"vals": ["10"]}}, + "collation": {"locale": "en", "numericOrdering": True}, + } + ], + }, + expected={"ok": 1.0, "n": 1, "nModified": 1}, + msg="$pullAll with numericOrdering should match numeric string values", + ), CommandTestCase( "pullall_no_collation_binary", docs=[{"_id": 1, "tags": ["Apple", "apple", "banana"]}], @@ -132,6 +180,39 @@ expected={"ok": 1.0, "n": 1, "nModified": 1}, msg="$pullAll without collation should use binary comparison", ), + CommandTestCase( + "pullall_collection_default_collation", + target_collection=CustomCollection(options={"collation": {"locale": "en", "strength": 2}}), + docs=[{"_id": 1, "tags": ["Apple", "banana"]}], + command=lambda ctx: { + "update": ctx.collection, + "updates": [ + { + "q": {"_id": 1}, + "u": {"$pullAll": {"tags": ["apple"]}}, + } + ], + }, + expected={"ok": 1.0, "n": 1, "nModified": 1}, + msg="$pullAll should inherit collection default collation", + ), + CommandTestCase( + "pullall_explicit_overrides_collection_default", + target_collection=CustomCollection(options={"collation": {"locale": "en", "strength": 2}}), + docs=[{"_id": 1, "tags": ["Apple", "apple", "banana"]}], + command=lambda ctx: { + "update": ctx.collection, + "updates": [ + { + "q": {"_id": 1}, + "u": {"$pullAll": {"tags": ["apple"]}}, + "collation": {"locale": "en", "strength": 3}, + } + ], + }, + expected={"ok": 1.0, "n": 1, "nModified": 1}, + msg="$pullAll explicit collation should override collection default", + ), ] # Property [AddToSet with Collation]: $addToSet uses collation to determine diff --git a/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/__init__.py b/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_argument_handling.py b/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_argument_handling.py new file mode 100644 index 000000000..eb7cd1dd9 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_argument_handling.py @@ -0,0 +1,61 @@ +"""Tests for $pullAll argument handling. + +Covers: null/missing field handling, empty operand, multiple fields. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.update.utils import UpdateTestCase +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +SUCCESS_TESTS: list[UpdateTestCase] = [ + UpdateTestCase( + "null_in_values_removes_null", + setup_docs=[{"_id": 1, "a": [1, None, 2, None, 3]}], + query={"_id": 1}, + update={"$pullAll": {"a": [None]}}, + expected={"_id": 1, "a": [1, 2, 3]}, + msg="Should remove null elements from array", + ), + UpdateTestCase( + "nonexistent_field_noop", + setup_docs=[{"_id": 1, "b": "other"}], + query={"_id": 1}, + update={"$pullAll": {"a": [1, 2]}}, + expected={"_id": 1, "b": "other"}, + msg="Should be no-op when field does not exist", + ), + UpdateTestCase( + "multiple_fields", + setup_docs=[{"_id": 1, "a": [1, 2, 3], "b": ["x", "y", "z"]}], + query={"_id": 1}, + update={"$pullAll": {"a": [2], "b": ["y"]}}, + expected={"_id": 1, "a": [1, 3], "b": ["x", "z"]}, + msg="Should process multiple fields independently", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(SUCCESS_TESTS)) +def test_pullAll_argument_handling(collection, test: UpdateTestCase): + """Test $pullAll null values, missing fields, and multiple fields handling.""" + collection.insert_many(test.setup_docs) + execute_command( + collection, + {"update": collection.name, "updates": [{"q": test.query, "u": test.update}]}, + ) + result = execute_command(collection, {"find": collection.name, "filter": test.query}) + assertSuccess(result, [test.expected], msg=test.msg) + + +def test_pullAll_empty_operand_noop(collection): + """Test $pullAll with empty operand expression {} is a no-op.""" + collection.insert_one({"_id": 1, "a": [1, 2, 3]}) + execute_command( + collection, + {"update": collection.name, "updates": [{"q": {"_id": 1}, "u": {"$pullAll": {}}}]}, + ) + result = execute_command(collection, {"find": collection.name, "filter": {"_id": 1}}) + assertSuccess(result, [{"_id": 1, "a": [1, 2, 3]}], msg="Empty $pullAll should be no-op") diff --git a/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_bson_type_validation.py b/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_bson_type_validation.py new file mode 100644 index 000000000..34df97c8b --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_bson_type_validation.py @@ -0,0 +1,107 @@ +"""Tests for $pullAll BSON type validation. + +Verifies that $pullAll rejects non-array target field types with error code 2, +rejects non-array argument types with error code 2, and can match any BSON type +as values to remove from an array. +""" + +import pytest +from bson import Binary + +from documentdb_tests.framework.assertions import assertFailureCode, assertSuccess +from documentdb_tests.framework.bson_type_validator import ( + BsonType, + BsonTypeTestCase, + generate_bson_acceptance_test_cases, + generate_bson_rejection_test_cases, +) +from documentdb_tests.framework.error_codes import BAD_VALUE_ERROR +from documentdb_tests.framework.executor import execute_command + +PULLALL_PARAMS = [ + BsonTypeTestCase( + id="target_field", + msg="$pullAll should error when the document field it targets is not an array", + valid_types=[BsonType.ARRAY], + default_error_code=BAD_VALUE_ERROR, + expected=[{"_id": 1, "arr": [2]}], + valid_inputs={BsonType.ARRAY: [1, 2]}, + ), + BsonTypeTestCase( + id="argument", + msg="$pullAll should reject non-array argument types", + valid_types=[BsonType.ARRAY], + default_error_code=BAD_VALUE_ERROR, + expected=[{"_id": 1, "arr": [1, 2, 3]}], + valid_inputs={BsonType.ARRAY: [99]}, + ), + BsonTypeTestCase( + id="value_element", + msg="$pullAll should accept any BSON type as value to match and remove", + valid_types=list(BsonType), + default_error_code=BAD_VALUE_ERROR, + expected=[{"_id": 1, "arr": []}], + valid_inputs={BsonType.BIN_DATA: Binary(b"\x00\x01\x02", 128)}, + ), +] + + +def _setup_doc(spec, sample_value) -> dict: + """Build the setup document based on which aspect is being tested.""" + if spec.id == "target_field": + return {"_id": 1, "arr": sample_value} + if spec.id == "argument": + return {"_id": 1, "arr": [1, 2, 3]} + return {"_id": 1, "arr": [sample_value]} + + +def _build_update(spec, sample_value) -> dict: + """Build the update command based on which aspect is being tested.""" + if spec.id == "target_field": + return {"$pullAll": {"arr": [1]}} + if spec.id == "argument": + return {"$pullAll": {"arr": sample_value}} + return {"$pullAll": {"arr": [sample_value]}} + + +@pytest.mark.parametrize( + "bson_type,sample_value,spec", generate_bson_rejection_test_cases(PULLALL_PARAMS) +) +def test_pullAll_bson_type_rejected(collection, bson_type, sample_value, spec): + """Test $pullAll rejects invalid BSON types with error.""" + setup_doc = _setup_doc(spec, sample_value) + update = _build_update(spec, sample_value) + collection.insert_one(setup_doc) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": update}], + }, + ) + assertFailureCode( + result, + spec.expected_code(bson_type), + msg=f"$pullAll should reject {bson_type.value} for {spec.id}", + ) + + +@pytest.mark.parametrize( + "bson_type,sample_value,spec", generate_bson_acceptance_test_cases(PULLALL_PARAMS) +) +def test_pullAll_bson_type_accepted(collection, bson_type, sample_value, spec): + """Test $pullAll accepts valid BSON types.""" + setup_doc = _setup_doc(spec, sample_value) + update = _build_update(spec, sample_value) + collection.insert_one(setup_doc) + execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": update}], + }, + ) + result = execute_command(collection, {"find": collection.name, "filter": {"_id": 1}}) + assertSuccess( + result, spec.expected, msg=f"$pullAll should accept {bson_type.value} for {spec.id}" + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_core_behavior.py b/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_core_behavior.py new file mode 100644 index 000000000..8a78e4f9f --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_core_behavior.py @@ -0,0 +1,91 @@ +"""Tests for $pullAll core behavior. + +Covers: removal of all instances, empty values list, all elements removed, +empty array target, duplicate values in values list, values not present. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.update.utils import UpdateTestCase +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +PULLALL_CORE_TESTS: list[UpdateTestCase] = [ + UpdateTestCase( + "removes_all_instances", + setup_docs=[{"_id": 1, "a": [1, 2, 3, 1, 2, 3]}], + query={"_id": 1}, + update={"$pullAll": {"a": [1, 3]}}, + expected={"_id": 1, "a": [2, 2]}, + msg="Should remove all instances of each specified value", + ), + UpdateTestCase( + "removes_multiple_occurrences", + setup_docs=[{"_id": 1, "a": [5, 5, 5, 6, 5]}], + query={"_id": 1}, + update={"$pullAll": {"a": [5]}}, + expected={"_id": 1, "a": [6]}, + msg="Should remove multiple occurrences of same value", + ), + UpdateTestCase( + "values_not_present_noop", + setup_docs=[{"_id": 1, "a": [1, 2, 3]}], + query={"_id": 1}, + update={"$pullAll": {"a": [99, 100]}}, + expected={"_id": 1, "a": [1, 2, 3]}, + msg="Should be no-op when values not present", + ), + UpdateTestCase( + "some_values_present_some_not", + setup_docs=[{"_id": 1, "a": [1, 2, 3, 4]}], + query={"_id": 1}, + update={"$pullAll": {"a": [2, 4, 99]}}, + expected={"_id": 1, "a": [1, 3]}, + msg="Should remove only matching values", + ), + UpdateTestCase( + "empty_values_list_noop", + setup_docs=[{"_id": 1, "a": [1, 2, 3]}], + query={"_id": 1}, + update={"$pullAll": {"a": []}}, + expected={"_id": 1, "a": [1, 2, 3]}, + msg="Should be no-op with empty values list", + ), + UpdateTestCase( + "removes_all_leaves_empty_array", + setup_docs=[{"_id": 1, "a": [1, 2, 3]}], + query={"_id": 1}, + update={"$pullAll": {"a": [1, 2, 3]}}, + expected={"_id": 1, "a": []}, + msg="Should leave empty array when all elements removed", + ), + UpdateTestCase( + "empty_array_noop", + setup_docs=[{"_id": 1, "a": []}], + query={"_id": 1}, + update={"$pullAll": {"a": [1, 2]}}, + expected={"_id": 1, "a": []}, + msg="Should be no-op on empty array", + ), + UpdateTestCase( + "duplicate_values_in_values_list", + setup_docs=[{"_id": 1, "a": [1, 2, 3, 1]}], + query={"_id": 1}, + update={"$pullAll": {"a": [1, 1, 1]}}, + expected={"_id": 1, "a": [2, 3]}, + msg="Duplicate values in values list should behave same as single", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(PULLALL_CORE_TESTS)) +def test_pullAll_core_behavior(collection, test: UpdateTestCase): + """Test $pullAll core removal behavior.""" + collection.insert_many(test.setup_docs) + execute_command( + collection, + {"update": collection.name, "updates": [{"q": test.query, "u": test.update}]}, + ) + result = execute_command(collection, {"find": collection.name, "filter": test.query}) + assertSuccess(result, [test.expected], msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_data_types.py b/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_data_types.py new file mode 100644 index 000000000..fba090d1d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_data_types.py @@ -0,0 +1,216 @@ +"""Tests for $pullAll data type matching semantics. + +Covers: numeric equivalence, BSON type distinction, NaN/Infinity handling, +cross-type NaN/Infinity/negative-zero equivalence. +""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.update.utils import UpdateTestCase +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_NAN, + DECIMAL128_NEGATIVE_ZERO, + DOUBLE_NEGATIVE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + FLOAT_NEGATIVE_NAN, +) + +NUMERIC_EQUIVALENCE_TESTS: list[UpdateTestCase] = [ + UpdateTestCase( + "mixed_types_in_values_list", + setup_docs=[{"_id": 1, "a": [1, 2.0, Int64(3), Decimal128("4")]}], + query={"_id": 1}, + update={"$pullAll": {"a": [Int64(1), 2, Decimal128("3"), 4.0]}}, + expected={"_id": 1, "a": []}, + msg="Should remove with mixed numeric types in values list", + ), +] + +BSON_TYPE_DISTINCTION_TESTS: list[UpdateTestCase] = [ + UpdateTestCase( + "false_does_not_remove_int_zero", + setup_docs=[{"_id": 1, "a": [0, False]}], + query={"_id": 1}, + update={"$pullAll": {"a": [False]}}, + expected={"_id": 1, "a": [0]}, + msg="false should NOT remove int(0) — distinct BSON types", + ), + UpdateTestCase( + "true_does_not_remove_int_one", + setup_docs=[{"_id": 1, "a": [1, True]}], + query={"_id": 1}, + update={"$pullAll": {"a": [True]}}, + expected={"_id": 1, "a": [1]}, + msg="true should NOT remove int(1) — distinct BSON types", + ), + UpdateTestCase( + "null_removes_only_null", + setup_docs=[{"_id": 1, "a": [None, 0, "", False]}], + query={"_id": 1}, + update={"$pullAll": {"a": [None]}}, + expected={"_id": 1, "a": [0, "", False]}, + msg="Should remove only null elements", + ), + UpdateTestCase( + "empty_string_does_not_remove_null", + setup_docs=[{"_id": 1, "a": [None, ""]}], + query={"_id": 1}, + update={"$pullAll": {"a": [""]}}, + expected={"_id": 1, "a": [None]}, + msg="empty string should NOT remove null — distinct types", + ), +] + +NAN_INFINITY_TESTS: list[UpdateTestCase] = [ + UpdateTestCase( + "float_nan_removes_nan", + setup_docs=[{"_id": 1, "a": [FLOAT_NAN, 1, 2]}], + query={"_id": 1}, + update={"$pullAll": {"a": [FLOAT_NAN]}}, + expected={"_id": 1, "a": [1, 2]}, + msg="Should remove NaN elements", + ), + UpdateTestCase( + "decimal128_nan_removes_nan", + setup_docs=[{"_id": 1, "a": [DECIMAL128_NAN, 1, 2]}], + query={"_id": 1}, + update={"$pullAll": {"a": [DECIMAL128_NAN]}}, + expected={"_id": 1, "a": [1, 2]}, + msg="Should remove Decimal128 NaN elements", + ), + UpdateTestCase( + "infinity_removes_infinity", + setup_docs=[{"_id": 1, "a": [FLOAT_INFINITY, 1, 2]}], + query={"_id": 1}, + update={"$pullAll": {"a": [FLOAT_INFINITY]}}, + expected={"_id": 1, "a": [1, 2]}, + msg="Should remove Infinity elements", + ), + UpdateTestCase( + "neg_infinity_removes_neg_infinity", + setup_docs=[{"_id": 1, "a": [FLOAT_NEGATIVE_INFINITY, 1, 2]}], + query={"_id": 1}, + update={"$pullAll": {"a": [FLOAT_NEGATIVE_INFINITY]}}, + expected={"_id": 1, "a": [1, 2]}, + msg="Should remove -Infinity elements", + ), + UpdateTestCase( + "neg_zero_removes_zero", + setup_docs=[{"_id": 1, "a": [0.0, 1, 2]}], + query={"_id": 1}, + update={"$pullAll": {"a": [DOUBLE_NEGATIVE_ZERO]}}, + expected={"_id": 1, "a": [1, 2]}, + msg="Should remove 0.0 via -0.0 (numeric equivalence)", + ), + UpdateTestCase( + "decimal128_neg_zero_removes_zero", + setup_docs=[{"_id": 1, "a": [Decimal128("0"), 1, 2]}], + query={"_id": 1}, + update={"$pullAll": {"a": [DECIMAL128_NEGATIVE_ZERO]}}, + expected={"_id": 1, "a": [1, 2]}, + msg="Should remove Decimal128 0 via Decimal128 -0 (numeric equivalence)", + ), + UpdateTestCase( + "float_nan_matches_decimal128_nan", + setup_docs=[{"_id": 1, "a": [DECIMAL128_NAN, 1, 2]}], + query={"_id": 1}, + update={"$pullAll": {"a": [FLOAT_NAN]}}, + expected={"_id": 1, "a": [1, 2]}, + msg="float NaN should match Decimal128 NaN (cross-type NaN equivalence)", + ), + UpdateTestCase( + "float_inf_matches_decimal128_inf", + setup_docs=[{"_id": 1, "a": [DECIMAL128_INFINITY, 1, 2]}], + query={"_id": 1}, + update={"$pullAll": {"a": [FLOAT_INFINITY]}}, + expected={"_id": 1, "a": [1, 2]}, + msg="float Infinity should match Decimal128 Infinity (cross-type equivalence)", + ), + UpdateTestCase( + "decimal128_neg_infinity_removes_self", + setup_docs=[{"_id": 1, "a": [DECIMAL128_NEGATIVE_INFINITY, 1, 2]}], + query={"_id": 1}, + update={"$pullAll": {"a": [DECIMAL128_NEGATIVE_INFINITY]}}, + expected={"_id": 1, "a": [1, 2]}, + msg="Should remove Decimal128 -Infinity elements", + ), + UpdateTestCase( + "float_neg_inf_matches_decimal128_neg_inf", + setup_docs=[{"_id": 1, "a": [DECIMAL128_NEGATIVE_INFINITY, 1, 2]}], + query={"_id": 1}, + update={"$pullAll": {"a": [FLOAT_NEGATIVE_INFINITY]}}, + expected={"_id": 1, "a": [1, 2]}, + msg="float -Infinity should match Decimal128 -Infinity (cross-type equivalence)", + ), + UpdateTestCase( + "float_neg_nan_removes_nan", + setup_docs=[{"_id": 1, "a": [FLOAT_NEGATIVE_NAN, 1, 2]}], + query={"_id": 1}, + update={"$pullAll": {"a": [FLOAT_NEGATIVE_NAN]}}, + expected={"_id": 1, "a": [1, 2]}, + msg="Should remove float -NaN elements", + ), + UpdateTestCase( + "decimal128_neg_nan_removes_nan", + setup_docs=[{"_id": 1, "a": [DECIMAL128_NEGATIVE_NAN, 1, 2]}], + query={"_id": 1}, + update={"$pullAll": {"a": [DECIMAL128_NEGATIVE_NAN]}}, + expected={"_id": 1, "a": [1, 2]}, + msg="Should remove Decimal128 -NaN elements", + ), + UpdateTestCase( + "float_nan_matches_float_neg_nan", + setup_docs=[{"_id": 1, "a": [FLOAT_NEGATIVE_NAN, 1, 2]}], + query={"_id": 1}, + update={"$pullAll": {"a": [FLOAT_NAN]}}, + expected={"_id": 1, "a": [1, 2]}, + msg="float NaN should match float -NaN (NaN sign equivalence)", + ), + UpdateTestCase( + "decimal128_nan_matches_decimal128_neg_nan", + setup_docs=[{"_id": 1, "a": [DECIMAL128_NEGATIVE_NAN, 1, 2]}], + query={"_id": 1}, + update={"$pullAll": {"a": [DECIMAL128_NAN]}}, + expected={"_id": 1, "a": [1, 2]}, + msg="Decimal128 NaN should match Decimal128 -NaN (NaN sign equivalence)", + ), + UpdateTestCase( + "float_neg_nan_matches_decimal128_nan", + setup_docs=[{"_id": 1, "a": [DECIMAL128_NAN, 1, 2]}], + query={"_id": 1}, + update={"$pullAll": {"a": [FLOAT_NEGATIVE_NAN]}}, + expected={"_id": 1, "a": [1, 2]}, + msg="float -NaN should match Decimal128 NaN (cross-type NaN equivalence)", + ), + UpdateTestCase( + "float_neg_zero_removes_decimal128_zero", + setup_docs=[{"_id": 1, "a": [Decimal128("0"), 1, 2]}], + query={"_id": 1}, + update={"$pullAll": {"a": [DOUBLE_NEGATIVE_ZERO]}}, + expected={"_id": 1, "a": [1, 2]}, + msg="float -0.0 should remove Decimal128 0 (cross-type negative zero equivalence)", + ), +] + +ALL_TESTS = NUMERIC_EQUIVALENCE_TESTS + BSON_TYPE_DISTINCTION_TESTS + NAN_INFINITY_TESTS + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_pullAll_data_type_matching(collection, test: UpdateTestCase): + """Test $pullAll data type matching semantics.""" + collection.insert_many(test.setup_docs) + execute_command( + collection, + {"update": collection.name, "updates": [{"q": test.query, "u": test.update}]}, + ) + result = execute_command(collection, {"find": collection.name, "filter": test.query}) + assertSuccess(result, [test.expected], msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_nested_fields.py b/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_nested_fields.py new file mode 100644 index 000000000..f4e14443b --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_nested_fields.py @@ -0,0 +1,90 @@ +"""Tests for $pullAll with dot notation and nested fields. + +Covers: deeply nested dot notation paths, intermediate path behavior, +positional operator, non-array argument rejection. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.update.utils import UpdateTestCase +from documentdb_tests.framework.assertions import assertFailureCode, assertSuccess +from documentdb_tests.framework.error_codes import BAD_VALUE_ERROR +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +NESTED_FIELD_TESTS: list[UpdateTestCase] = [ + UpdateTestCase( + "dot_notation_deep", + setup_docs=[{"_id": 1, "a": {"b": {"c": [10, 20, 30]}}}], + query={"_id": 1}, + update={"$pullAll": {"a.b.c": [20]}}, + expected={"_id": 1, "a": {"b": {"c": [10, 30]}}}, + msg="Should remove from deeply nested array", + ), + UpdateTestCase( + "intermediate_does_not_exist_noop", + setup_docs=[{"_id": 1, "x": 1}], + query={"_id": 1}, + update={"$pullAll": {"a.b": [1]}}, + expected={"_id": 1, "x": 1}, + msg="Should be no-op when intermediate path does not exist", + ), + UpdateTestCase( + "dot_notation_array_index", + setup_docs=[{"_id": 1, "a": [{"b": [1, 2, 3]}, {"b": [4, 5, 6]}]}], + query={"_id": 1}, + update={"$pullAll": {"a.0.b": [2, 3]}}, + expected={"_id": 1, "a": [{"b": [1]}, {"b": [4, 5, 6]}]}, + msg="Should pull from specific array element via numeric index in dot notation", + ), + UpdateTestCase( + "array_intermediate_no_traversal", + setup_docs=[{"_id": 1, "a": [{"b": [1, 2, 3]}, {"b": [2, 3, 4]}]}], + query={"_id": 1}, + update={"$pullAll": {"a.b": [2]}}, + expected={"_id": 1, "a": [{"b": [1, 2, 3]}, {"b": [2, 3, 4]}]}, + msg="Should be no-op when intermediate is an array without explicit index", + ), + UpdateTestCase( + "positional_operator", + setup_docs=[{"_id": 1, "a": [{"b": [1, 2, 3]}, {"b": [2, 4, 5]}]}], + query={"a.b": 2}, + update={"$pullAll": {"a.$.b": [2]}}, + expected={"_id": 1, "a": [{"b": [1, 3]}, {"b": [2, 4, 5]}]}, + msg="Should pull from first matched array element via positional operator", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(NESTED_FIELD_TESTS)) +def test_pullAll_nested_fields(collection, test: UpdateTestCase): + """Test $pullAll with dot notation and nested fields.""" + collection.insert_many(test.setup_docs) + execute_command( + collection, + {"update": collection.name, "updates": [{"q": test.query, "u": test.update}]}, + ) + result = execute_command(collection, {"find": collection.name, "filter": test.query}) + assertSuccess(result, [test.expected], msg=test.msg) + + +def test_pullAll_nested_object_argument_rejected(collection): + """Test $pullAll rejects object argument on nested path.""" + collection.insert_one({"_id": 1, "a": {"b": {"c": [10, 20, 30]}}}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [ + { + "q": {"_id": 1}, + "u": {"$pullAll": {"a.b.c": [20], "a.b": {"c": [10, 20, 30]}}}, + } + ], + }, + ) + assertFailureCode( + result, + BAD_VALUE_ERROR, + msg="$pullAll should reject object argument on nested path", + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_update_integration.py b/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_update_integration.py new file mode 100644 index 000000000..0d6500e1d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_update_integration.py @@ -0,0 +1,131 @@ +"""Tests for $pullAll update command integration. + +Covers: updateOne, updateMany, bulkWrite, upsert behavior, large arrays. +""" + +from documentdb_tests.framework.assertions import assertSuccess, assertSuccessPartial +from documentdb_tests.framework.executor import execute_command + + +def test_pullAll_updateOne(collection): + """Test $pullAll with updateOne only modifies one document when multiple match.""" + collection.insert_many( + [ + {"q": 1, "a": [1, 2, 3]}, + {"q": 1, "a": [1, 2, 3]}, + ] + ) + result = execute_command( + collection, + {"update": collection.name, "updates": [{"q": {"q": 1}, "u": {"$pullAll": {"a": [2, 3]}}}]}, + ) + assertSuccessPartial( + result, {"n": 1, "nModified": 1, "ok": 1.0}, msg="updateOne should succeed" + ) + + +def test_pullAll_updateMany(collection): + """Test $pullAll with updateMany processes each matched document independently.""" + collection.insert_many( + [ + {"_id": 1, "a": [1, 2, 3]}, + {"_id": 2, "a": [2, 3, 4]}, + {"_id": 3, "a": [5, 6, 7]}, + ] + ) + execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {}, "u": {"$pullAll": {"a": [2, 3]}}, "multi": True}], + }, + ) + result = execute_command( + collection, {"find": collection.name, "filter": {}, "sort": {"_id": 1}} + ) + assertSuccess( + result, + [ + {"_id": 1, "a": [1]}, + {"_id": 2, "a": [4]}, + {"_id": 3, "a": [5, 6, 7]}, + ], + msg="updateMany should process each doc independently", + ) + + +def test_pullAll_bulkWrite(collection): + """Test $pullAll in bulkWrite updates multiple documents correctly.""" + collection.insert_many( + [ + {"_id": 1, "a": [1, 2, 3]}, + {"_id": 2, "a": [4, 5, 6]}, + ] + ) + execute_command( + collection, + { + "update": collection.name, + "updates": [ + {"q": {"_id": 1}, "u": {"$pullAll": {"a": [1]}}}, + {"q": {"_id": 2}, "u": {"$pullAll": {"a": [5, 6]}}}, + ], + }, + ) + result = execute_command( + collection, {"find": collection.name, "filter": {}, "sort": {"_id": 1}} + ) + assertSuccess( + result, + [{"_id": 1, "a": [2, 3]}, {"_id": 2, "a": [4]}], + msg="bulkWrite should update both docs correctly", + ) + + +def test_pullAll_upsert_creates_doc_without_array(collection): + """Test $pullAll with upsert:true creates doc without array field.""" + execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 99}, "u": {"$pullAll": {"a": [1, 2]}}, "upsert": True}], + }, + ) + result = execute_command(collection, {"find": collection.name, "filter": {"_id": 99}}) + assertSuccess(result, [{"_id": 99}], msg="Upsert should create doc without array field") + + +def test_pullAll_large_array(collection): + """Test $pullAll removing many values from a large array.""" + large_array = list(range(200)) + collection.insert_one({"_id": 1, "a": large_array}) + values_to_remove = list(range(0, 200, 2)) + execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$pullAll": {"a": values_to_remove}}}], + }, + ) + result = execute_command(collection, {"find": collection.name, "filter": {"_id": 1}}) + assertSuccess( + result, + [{"_id": 1, "a": list(range(1, 200, 2))}], + msg="Should remove even numbers from large array", + ) + + +def test_pullAll_large_values_list(collection): + """Test $pullAll with large values list (100+ values).""" + collection.insert_one({"_id": 1, "a": list(range(50))}) + execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$pullAll": {"a": list(range(150))}}}], + }, + ) + result = execute_command(collection, {"find": collection.name, "filter": {"_id": 1}}) + assertSuccess( + result, [{"_id": 1, "a": []}], msg="Should remove all elements with large values list" + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_value_matching.py b/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_value_matching.py new file mode 100644 index 000000000..5bdfe5953 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_value_matching.py @@ -0,0 +1,115 @@ +"""Tests for $pullAll value matching behavior. + +Covers: array element matching (order-sensitive), document matching +(field-order-sensitive), input correlation, mixed-type exact matching. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.update.utils import UpdateTestCase +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +PULLALL_VALUE_MATCHING_TESTS: list[UpdateTestCase] = [ + UpdateTestCase( + "array_exact_match_removes", + setup_docs=[{"_id": 1, "a": [[1, 2, 3], [4, 5, 6]]}], + query={"_id": 1}, + update={"$pullAll": {"a": [[1, 2, 3]]}}, + expected={"_id": 1, "a": [[4, 5, 6]]}, + msg="Should remove exact array matches", + ), + UpdateTestCase( + "array_different_order_no_remove", + setup_docs=[{"_id": 1, "a": [[1, 2, 3], [4, 5, 6]]}], + query={"_id": 1}, + update={"$pullAll": {"a": [[3, 2, 1]]}}, + expected={"_id": 1, "a": [[1, 2, 3], [4, 5, 6]]}, + msg="Should NOT remove array with different order", + ), + UpdateTestCase( + "values_list_removes_individual_not_subarray", + setup_docs=[{"_id": 1, "a": [1, 2, 3, [1, 2, 3]]}], + query={"_id": 1}, + update={"$pullAll": {"a": [1, 2, 3]}}, + expected={"_id": 1, "a": [[1, 2, 3]]}, + msg="Should remove individual elements, not the subarray", + ), + UpdateTestCase( + "doc_exact_match_removes", + setup_docs=[{"_id": 1, "a": [{"a": 1, "b": 2}, {"c": 3}]}], + query={"_id": 1}, + update={"$pullAll": {"a": [{"a": 1, "b": 2}]}}, + expected={"_id": 1, "a": [{"c": 3}]}, + msg="Should remove document with exact field order match", + ), + UpdateTestCase( + "doc_different_field_order_no_remove", + setup_docs=[{"_id": 1, "a": [{"a": 1, "b": 2}, {"c": 3}]}], + query={"_id": 1}, + update={"$pullAll": {"a": [{"b": 2, "a": 1}]}}, + expected={"_id": 1, "a": [{"a": 1, "b": 2}, {"c": 3}]}, + msg="Should NOT remove document with different field order (unlike $pull)", + ), + UpdateTestCase( + "nested_doc_field_order_must_match", + setup_docs=[{"_id": 1, "a": [{"x": {"a": 1, "b": 2}}]}], + query={"_id": 1}, + update={"$pullAll": {"a": [{"x": {"b": 2, "a": 1}}]}}, + expected={"_id": 1, "a": [{"x": {"a": 1, "b": 2}}]}, + msg="Nested document field order must match at all levels", + ), + UpdateTestCase( + "nested_doc_exact_match_removes", + setup_docs=[{"_id": 1, "a": [{"x": {"a": 1, "b": 2}}]}], + query={"_id": 1}, + update={"$pullAll": {"a": [{"x": {"a": 1, "b": 2}}]}}, + expected={"_id": 1, "a": []}, + msg="Should remove nested document with exact match", + ), + UpdateTestCase( + "only_exact_field_order_removed", + setup_docs=[{"_id": 1, "a": [{"a": 1, "b": 2}, {"b": 2, "a": 1}, {"a": 1, "b": 2}]}], + query={"_id": 1}, + update={"$pullAll": {"a": [{"a": 1, "b": 2}]}}, + expected={"_id": 1, "a": [{"b": 2, "a": 1}]}, + msg="Should only remove exact field-order matches", + ), + UpdateTestCase( + "only_exact_element_order_removed", + setup_docs=[{"_id": 1, "a": [[1, 2], [2, 1], [1, 2]]}], + query={"_id": 1}, + update={"$pullAll": {"a": [[1, 2]]}}, + expected={"_id": 1, "a": [[2, 1]]}, + msg="Should only remove exact element-order matches", + ), + UpdateTestCase( + "array_values_mixed_types_exact_match", + setup_docs=[{"_id": 1, "a": [[1, "two", True], [True, "two", 1]]}], + query={"_id": 1}, + update={"$pullAll": {"a": [[1, "two", True]]}}, + expected={"_id": 1, "a": [[True, "two", 1]]}, + msg="Should use exact match for array values with mixed types", + ), + UpdateTestCase( + "partial_doc_does_not_match", + setup_docs=[{"_id": 1, "a": [{"a": 1, "b": 2, "c": 3}, {"d": 4}]}], + query={"_id": 1}, + update={"$pullAll": {"a": [{"a": 1, "b": 2}]}}, + expected={"_id": 1, "a": [{"a": 1, "b": 2, "c": 3}, {"d": 4}]}, + msg="Partial document should NOT match (unlike $pull which does subset matching)", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(PULLALL_VALUE_MATCHING_TESTS)) +def test_pullAll_value_matching(collection, test: UpdateTestCase): + """Test $pullAll value matching semantics.""" + collection.insert_many(test.setup_docs) + execute_command( + collection, + {"update": collection.name, "updates": [{"q": test.query, "u": test.update}]}, + ) + result = execute_command(collection, {"find": collection.name, "filter": test.query}) + assertSuccess(result, [test.expected], msg=test.msg) From a0d698a93325a4a437a1efe2a67174e6111468d0 Mon Sep 17 00:00:00 2001 From: "Alina (Xi) Li" <96995091+alinaliBQ@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:41:40 -0700 Subject: [PATCH 04/35] abort Transaction tests (#600) Signed-off-by: Alina (Xi) Li Signed-off-by: RyanGarfinkel <113050972+RyanGarfinkel@users.noreply.github.com> --- .../commands/abortTransaction/__init__.py | 0 .../test_abortTransaction_atomicity.py | 27 ++ .../test_abortTransaction_autocommit_error.py | 116 +++++++++ ...rtTransaction_cannot_commit_after_abort.py | 54 ++++ .../test_abortTransaction_comment.py | 145 +++++++++++ .../test_abortTransaction_core.py | 64 +++++ .../test_abortTransaction_core_error.py | 94 +++++++ ...t_abortTransaction_failed_op_blocks_txn.py | 37 +++ .../test_abortTransaction_field_types.py | 177 +++++++++++++ .../test_abortTransaction_recovers_session.py | 35 +++ .../test_abortTransaction_txn_number_error.py | 131 ++++++++++ ...rtTransaction_unrecognized_fields_error.py | 106 ++++++++ .../test_abortTransaction_writeconcern.py | 194 ++++++++++++++ ...est_abortTransaction_writeconcern_error.py | 239 ++++++++++++++++++ documentdb_tests/framework/error_codes.py | 4 + 15 files changed, 1423 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_atomicity.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_autocommit_error.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_cannot_commit_after_abort.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_comment.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_core.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_core_error.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_failed_op_blocks_txn.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_field_types.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_recovers_session.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_txn_number_error.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_unrecognized_fields_error.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_writeconcern.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_writeconcern_error.py diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/__init__.py b/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_atomicity.py b/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_atomicity.py new file mode 100644 index 000000000..39ce445ca --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_atomicity.py @@ -0,0 +1,27 @@ +"""Test cross-collection atomicity of an aborted transaction. + +Writes to more than one collection in a single transaction are rolled back +atomically: after abort, neither collection retains its write. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.executor import execute_command + +pytestmark = [pytest.mark.admin, pytest.mark.requires(transactions=True)] + + +def test_abort_rolls_back_writes_across_collections(collection): + """Aborting rolls back writes made to a second collection in the same txn.""" + client = collection.database.client + other = collection.database[f"{collection.name}_other"] + with client.start_session() as session: + session.start_transaction() + collection.insert_one({"_id": 1}, session=session) + other.insert_one({"_id": 2}, session=session) + session.abort_transaction() + readback = execute_command(collection, {"find": other.name, "filter": {}}) + assertSuccess(readback, []) diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_autocommit_error.py b/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_autocommit_error.py new file mode 100644 index 000000000..e471c64f2 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_autocommit_error.py @@ -0,0 +1,116 @@ +"""Tests for abortTransaction autocommit parameter validation. + +Validates type and value acceptance for the autocommit parameter. Per the +MongoDB documentation, autocommit must be literal boolean false. Boolean true +produces InvalidOptions, non-boolean types produce TypeMismatch, and null is +treated as omitted (falls through to NoSuchTransaction). +""" + +from __future__ import annotations + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import ( + COMMAND_FAILED_ERROR, + INVALID_OPTIONS_ERROR, + TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = [pytest.mark.admin, pytest.mark.requires(transactions=True)] + + +# Property [autocommit Boolean Values]: autocommit accepts only boolean values. +AUTOCOMMIT_BOOLEAN_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "autocommit_bool_false", + command={"abortTransaction": 1, "autocommit": False}, + error_code=INVALID_OPTIONS_ERROR, + msg="abortTransaction should reject autocommit:false outside txn with InvalidOptions", + ), + CommandTestCase( + "autocommit_bool_true", + command={"abortTransaction": 1, "autocommit": True}, + error_code=INVALID_OPTIONS_ERROR, + msg="abortTransaction should reject autocommit:true with InvalidOptions", + ), +] + +# Property [autocommit Type Strictness]: non-boolean types are rejected with TypeMismatch. +AUTOCOMMIT_TYPE_REJECTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "autocommit_int32_zero", + command={"abortTransaction": 1, "autocommit": 0}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject autocommit:0 (int32) as wrong type", + ), + CommandTestCase( + "autocommit_int32_one", + command={"abortTransaction": 1, "autocommit": 1}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject autocommit:1 (int32) as wrong type", + ), + CommandTestCase( + "autocommit_int64_zero", + command={"abortTransaction": 1, "autocommit": Int64(0)}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject autocommit:Int64(0) as wrong type", + ), + CommandTestCase( + "autocommit_double_zero", + command={"abortTransaction": 1, "autocommit": 0.0}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject autocommit:0.0 as wrong type", + ), + CommandTestCase( + "autocommit_decimal128_zero", + command={"abortTransaction": 1, "autocommit": Decimal128("0")}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject autocommit:Decimal128('0') as wrong type", + ), + CommandTestCase( + "autocommit_string", + command={"abortTransaction": 1, "autocommit": "false"}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject autocommit:'false' (string) as wrong type", + ), + CommandTestCase( + "autocommit_object", + command={"abortTransaction": 1, "autocommit": {}}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject autocommit:{} (object) as wrong type", + ), + CommandTestCase( + "autocommit_array", + command={"abortTransaction": 1, "autocommit": []}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject autocommit:[] (array) as wrong type", + ), +] + +# Property [autocommit Null Handling]: null autocommit is treated as omitted. +AUTOCOMMIT_NULL_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "autocommit_null", + command={"abortTransaction": 1, "autocommit": None}, + error_code=COMMAND_FAILED_ERROR, + msg="abortTransaction should treat autocommit:null as omitted", + ), +] + +AUTOCOMMIT_TESTS: list[CommandTestCase] = ( + AUTOCOMMIT_BOOLEAN_TESTS + AUTOCOMMIT_TYPE_REJECTION_TESTS + AUTOCOMMIT_NULL_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(AUTOCOMMIT_TESTS)) +def test_abortTransaction_autocommit_error(collection, test): + """Test abortTransaction autocommit error cases.""" + result = execute_admin_command(collection, test.command) + assertFailureCode(result, test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_cannot_commit_after_abort.py b/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_cannot_commit_after_abort.py new file mode 100644 index 000000000..6369a8a49 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_cannot_commit_after_abort.py @@ -0,0 +1,54 @@ +"""Test that committing a transaction that was already aborted fails. + +Once a transaction is aborted, sending commitTransaction for that same +transaction (same session id and txnNumber) fails with NoSuchTransaction. The +transaction is driven with explicit session fields so the same txnNumber can be +referenced across the start, abort, and commit commands. A dedicated client is +used for this hand-managed session so it does not disturb the shared client's +server-session pool (which would conflict with other tests under parallel runs). +""" + +from __future__ import annotations + +import uuid + +import pytest +from bson import Binary, Int64 +from pymongo import MongoClient + +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import NO_SUCH_TRANSACTION_ERROR +from documentdb_tests.framework.executor import execute_admin_command, execute_command + +pytestmark = [pytest.mark.admin, pytest.mark.requires(transactions=True)] + + +def test_cannot_commit_after_abort(collection, connection_string): + """Committing an already-aborted transaction fails with NoSuchTransaction.""" + lsid = {"id": Binary(uuid.uuid4().bytes, 4)} # session id (UUID) + txn_number = Int64(1) + probe_client = MongoClient(connection_string) + try: + probe_collection = probe_client[collection.database.name][collection.name] + execute_command( + probe_collection, + { + "insert": collection.name, + "documents": [{"_id": 1}], + "lsid": lsid, + "txnNumber": txn_number, + "startTransaction": True, + "autocommit": False, + }, + ) + execute_admin_command( + probe_collection, + {"abortTransaction": 1, "lsid": lsid, "txnNumber": txn_number, "autocommit": False}, + ) + result = execute_admin_command( + probe_collection, + {"commitTransaction": 1, "lsid": lsid, "txnNumber": txn_number, "autocommit": False}, + ) + finally: + probe_client.close() + assertFailureCode(result, NO_SUCH_TRANSACTION_ERROR) diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_comment.py b/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_comment.py new file mode 100644 index 000000000..33bf4a3fb --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_comment.py @@ -0,0 +1,145 @@ +"""Tests for abortTransaction comment parameter type acceptance in a real transaction. + +Validates that the comment parameter accepts any BSON type when +abortTransaction is issued inside an active transaction on a replica set. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertSuccessPartial +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = [pytest.mark.admin, pytest.mark.requires(transactions=True)] + +# Property [comment Type Acceptance]: comment accepts any BSON type. +COMMENT_TYPE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "comment_string", + command={"abortTransaction": 1, "comment": "test comment"}, + msg="abortTransaction should accept comment:string", + ), + CommandTestCase( + "comment_string_empty", + command={"abortTransaction": 1, "comment": ""}, + msg="abortTransaction should accept comment:empty string", + ), + CommandTestCase( + "comment_int32", + command={"abortTransaction": 1, "comment": 42}, + msg="abortTransaction should accept comment:int32", + ), + CommandTestCase( + "comment_int64", + command={"abortTransaction": 1, "comment": Int64(42)}, + msg="abortTransaction should accept comment:Int64", + ), + CommandTestCase( + "comment_double", + command={"abortTransaction": 1, "comment": 3.14}, + msg="abortTransaction should accept comment:double", + ), + CommandTestCase( + "comment_decimal128", + command={"abortTransaction": 1, "comment": Decimal128("1.5")}, + msg="abortTransaction should accept comment:Decimal128", + ), + CommandTestCase( + "comment_bool_true", + command={"abortTransaction": 1, "comment": True}, + msg="abortTransaction should accept comment:true", + ), + CommandTestCase( + "comment_bool_false", + command={"abortTransaction": 1, "comment": False}, + msg="abortTransaction should accept comment:false", + ), + CommandTestCase( + "comment_null", + command={"abortTransaction": 1, "comment": None}, + msg="abortTransaction should accept comment:null", + ), + CommandTestCase( + "comment_object", + command={"abortTransaction": 1, "comment": {"key": "value"}}, + msg="abortTransaction should accept comment:object", + ), + CommandTestCase( + "comment_object_empty", + command={"abortTransaction": 1, "comment": {}}, + msg="abortTransaction should accept comment:empty object", + ), + CommandTestCase( + "comment_array", + command={"abortTransaction": 1, "comment": [1, 2, 3]}, + msg="abortTransaction should accept comment:array", + ), + CommandTestCase( + "comment_array_empty", + command={"abortTransaction": 1, "comment": []}, + msg="abortTransaction should accept comment:empty array", + ), + CommandTestCase( + "comment_objectid", + command={"abortTransaction": 1, "comment": ObjectId()}, + msg="abortTransaction should accept comment:ObjectId", + ), + CommandTestCase( + "comment_datetime", + command={ + "abortTransaction": 1, + "comment": datetime(2024, 1, 1, tzinfo=timezone.utc), + }, + msg="abortTransaction should accept comment:datetime", + ), + CommandTestCase( + "comment_binary", + command={"abortTransaction": 1, "comment": Binary(b"\x00")}, + msg="abortTransaction should accept comment:Binary", + ), + CommandTestCase( + "comment_regex", + command={"abortTransaction": 1, "comment": Regex(".*")}, + msg="abortTransaction should accept comment:Regex", + ), + CommandTestCase( + "comment_timestamp", + command={"abortTransaction": 1, "comment": Timestamp(0, 0)}, + msg="abortTransaction should accept comment:Timestamp", + ), + CommandTestCase( + "comment_minkey", + command={"abortTransaction": 1, "comment": MinKey()}, + msg="abortTransaction should accept comment:MinKey", + ), + CommandTestCase( + "comment_maxkey", + command={"abortTransaction": 1, "comment": MaxKey()}, + msg="abortTransaction should accept comment:MaxKey", + ), + CommandTestCase( + "comment_code", + command={"abortTransaction": 1, "comment": Code("function(){}")}, + msg="abortTransaction should accept comment:Code", + ), +] + + +@pytest.mark.admin +@pytest.mark.parametrize("test", pytest_params(COMMENT_TYPE_TESTS)) +def test_abortTransaction_comment(collection, test): + """Test abortTransaction comment parameter type acceptance in a transaction.""" + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + collection.insert_one({"_id": 1}, session=session) + result = execute_admin_command(collection, test.command, session=session) + assertSuccessPartial(result, {"ok": 1.0}, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_core.py b/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_core.py new file mode 100644 index 000000000..3bff3216f --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_core.py @@ -0,0 +1,64 @@ +"""Tests for abortTransaction command success behavior. + +Each test drives the full transaction lifecycle inline (start session, start +transaction, run an operation, abort) so that setup -> act -> assert reads top +to bottom. These cover the fundamental abort behaviors: an aborted write is +rolled back, pre-transaction data survives the abort, an empty transaction +aborts, and the response carries ok:1. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.framework.assertions import ( + assertNotError, + assertSuccess, + assertSuccessPartial, +) +from documentdb_tests.framework.executor import execute_admin_command, execute_command + +pytestmark = [pytest.mark.admin, pytest.mark.requires(transactions=True)] + + +def test_abortTransaction_rolls_back_insert(collection): + """An aborted insert leaves no trace after the transaction aborts.""" + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + collection.insert_one({"_id": 1, "x": "inserted"}, session=session) + session.abort_transaction() + readback = execute_command(collection, {"find": collection.name, "filter": {}}) + assertSuccess(readback, []) + + +def test_pre_existing_data_survives_abort(collection): + """Documents inserted before the transaction survive an abort.""" + collection.insert_one({"_id": 1, "x": "seed"}) + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + collection.insert_one({"_id": 2, "x": "txn"}, session=session) + session.abort_transaction() + readback = execute_command(collection, {"find": collection.name, "filter": {}}) + assertSuccess(readback, [{"_id": 1, "x": "seed"}]) + + +def test_abortTransaction_empty(collection): + """Aborting a transaction with no operations succeeds.""" + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + session.abort_transaction() + readback = execute_command(collection, {"find": collection.name, "filter": {}}) + assertNotError(readback, msg="abortTransaction on an empty transaction should not error") + + +def test_abortTransaction_response_ok(collection): + """The abortTransaction command response reports ok:1 on success.""" + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + collection.insert_one({"_id": 1}, session=session) + result = execute_admin_command(collection, {"abortTransaction": 1}, session=session) + assertSuccessPartial(result, {"ok": 1.0}) diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_core_error.py b/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_core_error.py new file mode 100644 index 000000000..6abd9770c --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_core_error.py @@ -0,0 +1,94 @@ +"""Tests for abortTransaction command core error cases. + +Validates fundamental command error behavior including parameter acceptance, +parameter interactions, and the admin database requirement. On a replica set, +commands with autocommit + txnNumber produce TransactionTooOld (251), and +txnNumber alone produces NotARetryableWriteCommand (50768). +""" + +from __future__ import annotations + +import pytest +from bson import Binary, Int64 + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import ( + COMMAND_FAILED_ERROR, + NOT_A_RETRYABLE_WRITE_COMMAND_ERROR, + TRANSACTION_TOO_OLD_ERROR, + UNAUTHORIZED_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = [pytest.mark.admin, pytest.mark.requires(transactions=True)] + +# Property [Parameter Acceptance]: all valid parameters combined are syntactically accepted. +CORE_PARAMETER_ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "all_valid_params_no_parse_error", + command={ + "abortTransaction": 1, + "autocommit": False, + "txnNumber": Int64(1), + "writeConcern": {"w": "majority", "j": True, "wtimeout": 10_000}, + "comment": "full abort", + }, + error_code=TRANSACTION_TOO_OLD_ERROR, + msg="All valid params combined should not produce a parsing error", + ), +] + +# Property [Parameter Interactions]: combinations of valid parameters behave correctly. +CORE_PARAMETER_INTERACTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "interaction_txn_number_only", + command={"abortTransaction": 1, "txnNumber": Int64(1)}, + error_code=NOT_A_RETRYABLE_WRITE_COMMAND_ERROR, + msg="abortTransaction with txnNumber only should fail with NotARetryableWriteCommand", + ), + CommandTestCase( + "interaction_autocommit_txn_number", + command={"abortTransaction": 1, "autocommit": False, "txnNumber": Int64(1)}, + error_code=TRANSACTION_TOO_OLD_ERROR, + msg="abortTransaction with autocommit + txnNumber should fail with TransactionTooOld", + ), + CommandTestCase( + "lsid_recognized_field", + command={"abortTransaction": 1, "lsid": {"id": Binary(b"\x00" * 16, 4)}}, + error_code=COMMAND_FAILED_ERROR, + msg="lsid should be recognized (CommandFailed, not UnrecognizedField)", + ), +] + +CORE_ERROR_TESTS: list[CommandTestCase] = ( + CORE_PARAMETER_ACCEPTANCE_TESTS + CORE_PARAMETER_INTERACTION_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(CORE_ERROR_TESTS)) +def test_abortTransaction_core_error(collection, test): + """Test abortTransaction core error cases.""" + result = execute_admin_command(collection, test.command) + assertFailureCode(result, test.error_code, msg=test.msg) + + +# Property [Admin Database Requirement]: abortTransaction must run against the admin database. +ADMIN_DB_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "non_admin_database", + command={"abortTransaction": 1}, + error_code=UNAUTHORIZED_ERROR, + msg="abortTransaction on a non-admin database should fail with Unauthorized", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(ADMIN_DB_TESTS)) +def test_abortTransaction_admin_db_required(collection, test): + """Test abortTransaction requires admin database.""" + result = execute_command(collection, test.command) + assertFailureCode(result, test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_failed_op_blocks_txn.py b/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_failed_op_blocks_txn.py new file mode 100644 index 000000000..057cad3c7 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_failed_op_blocks_txn.py @@ -0,0 +1,37 @@ +"""Test that a failed operation blocks the rest of the transaction. + +After a write inside a transaction fails (here, a duplicate-key error), the +transaction is aborted by the server, so a later operation in the same +transaction fails with NoSuchTransaction. The transaction must be aborted and +restarted to recover (covered separately). +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import NO_SUCH_TRANSACTION_ERROR +from documentdb_tests.framework.executor import execute_command + +pytestmark = [pytest.mark.admin, pytest.mark.requires(transactions=True)] + + +def test_failed_op_blocks_rest_of_transaction(collection): + """A later op after a failed op in the same transaction fails with NoSuchTransaction.""" + collection.insert_one({"_id": 1}) # pre-existing doc so the txn insert collides + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + # Duplicate-key write (11000) fails and aborts the transaction. + execute_command( + collection, + {"insert": collection.name, "documents": [{"_id": 1}]}, + session=session, + ) + later = execute_command( + collection, + {"insert": collection.name, "documents": [{"_id": 2}]}, + session=session, + ) + assertFailureCode(later, NO_SUCH_TRANSACTION_ERROR) diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_field_types.py b/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_field_types.py new file mode 100644 index 000000000..5d8235dcc --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_field_types.py @@ -0,0 +1,177 @@ +"""Tests for abortTransaction command field type acceptance in a real transaction. + +Validates that the abortTransaction command's primary field accepts all BSON +types when issued inside an active transaction on a replica set. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertSuccessPartial +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = [pytest.mark.admin, pytest.mark.requires(transactions=True)] + +# Property [Field Type Acceptance]: the command field accepts any BSON type. +FIELD_TYPE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "field_int32_positive", + command={"abortTransaction": 1}, + msg="abortTransaction should accept int32 positive value", + ), + CommandTestCase( + "field_int32_negative", + command={"abortTransaction": -1}, + msg="abortTransaction should accept int32 negative value", + ), + CommandTestCase( + "field_int32_zero", + command={"abortTransaction": 0}, + msg="abortTransaction should accept int32 zero value", + ), + CommandTestCase( + "field_int64", + command={"abortTransaction": Int64(1)}, + msg="abortTransaction should accept int64 value", + ), + CommandTestCase( + "field_int64_max", + command={"abortTransaction": Int64(9_223_372_036_854_775_807)}, + msg="abortTransaction should accept int64 max value", + ), + CommandTestCase( + "field_double", + command={"abortTransaction": 1.0}, + msg="abortTransaction should accept double value", + ), + CommandTestCase( + "field_double_negative", + command={"abortTransaction": -1.0}, + msg="abortTransaction should accept negative double value", + ), + CommandTestCase( + "field_double_zero", + command={"abortTransaction": 0.0}, + msg="abortTransaction should accept double zero value", + ), + CommandTestCase( + "field_decimal128", + command={"abortTransaction": Decimal128("1")}, + msg="abortTransaction should accept Decimal128 value", + ), + CommandTestCase( + "field_bool_true", + command={"abortTransaction": True}, + msg="abortTransaction should accept bool true value", + ), + CommandTestCase( + "field_bool_false", + command={"abortTransaction": False}, + msg="abortTransaction should accept bool false value", + ), + CommandTestCase( + "field_nan", + command={"abortTransaction": float("nan")}, + msg="abortTransaction should accept NaN value", + ), + CommandTestCase( + "field_infinity", + command={"abortTransaction": float("inf")}, + msg="abortTransaction should accept Infinity value", + ), + CommandTestCase( + "field_string", + command={"abortTransaction": "abortTransaction"}, + msg="abortTransaction should accept string value", + ), + CommandTestCase( + "field_string_empty", + command={"abortTransaction": ""}, + msg="abortTransaction should accept empty string value", + ), + CommandTestCase( + "field_null", + command={"abortTransaction": None}, + msg="abortTransaction should accept null value", + ), + CommandTestCase( + "field_object_empty", + command={"abortTransaction": {}}, + msg="abortTransaction should accept empty object value", + ), + CommandTestCase( + "field_object_nonempty", + command={"abortTransaction": {"key": "value"}}, + msg="abortTransaction should accept non-empty object value", + ), + CommandTestCase( + "field_array_empty", + command={"abortTransaction": []}, + msg="abortTransaction should accept empty array value", + ), + CommandTestCase( + "field_array_nonempty", + command={"abortTransaction": [1, 2]}, + msg="abortTransaction should accept non-empty array value", + ), + CommandTestCase( + "field_binary", + command={"abortTransaction": Binary(b"\x00")}, + msg="abortTransaction should accept Binary value", + ), + CommandTestCase( + "field_objectid", + command={"abortTransaction": ObjectId()}, + msg="abortTransaction should accept ObjectId value", + ), + CommandTestCase( + "field_datetime", + command={"abortTransaction": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + msg="abortTransaction should accept datetime value", + ), + CommandTestCase( + "field_regex", + command={"abortTransaction": Regex(".*")}, + msg="abortTransaction should accept Regex value", + ), + CommandTestCase( + "field_timestamp", + command={"abortTransaction": Timestamp(0, 0)}, + msg="abortTransaction should accept Timestamp value", + ), + CommandTestCase( + "field_code", + command={"abortTransaction": Code("function(){}")}, + msg="abortTransaction should accept Code value", + ), + CommandTestCase( + "field_minkey", + command={"abortTransaction": MinKey()}, + msg="abortTransaction should accept MinKey value", + ), + CommandTestCase( + "field_maxkey", + command={"abortTransaction": MaxKey()}, + msg="abortTransaction should accept MaxKey value", + ), +] + + +@pytest.mark.admin +@pytest.mark.parametrize("test", pytest_params(FIELD_TYPE_TESTS)) +def test_abortTransaction_field_types(collection, test): + """Test abortTransaction command field type acceptance in a transaction.""" + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + collection.insert_one({"_id": 1}, session=session) + result = execute_admin_command(collection, test.command, session=session) + assertSuccessPartial(result, {"ok": 1.0}, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_recovers_session.py b/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_recovers_session.py new file mode 100644 index 000000000..f1551c3bb --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_recovers_session.py @@ -0,0 +1,35 @@ +"""Test that aborting recovers a session after a failed operation. + +After a write inside a transaction fails and the transaction is aborted, the +same session can start and commit a fresh transaction successfully. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.executor import execute_command + +pytestmark = [pytest.mark.admin, pytest.mark.requires(transactions=True)] + + +def test_abort_recovers_session_for_new_transaction(collection): + """After a failed op and abort, the same session can run a new transaction.""" + collection.insert_one({"_id": 1}) # pre-existing doc so the first txn insert collides + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + # Duplicate-key write fails and aborts the transaction. + execute_command( + collection, + {"insert": collection.name, "documents": [{"_id": 1}]}, + session=session, + ) + session.abort_transaction() + # The same session starts a fresh transaction and commits successfully. + session.start_transaction() + collection.insert_one({"_id": 2, "x": "recovered"}, session=session) + session.commit_transaction() + readback = execute_command(collection, {"find": collection.name, "filter": {"_id": 2}}) + assertSuccess(readback, [{"_id": 2, "x": "recovered"}]) diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_txn_number_error.py b/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_txn_number_error.py new file mode 100644 index 000000000..e07f550d9 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_txn_number_error.py @@ -0,0 +1,131 @@ +"""Tests for abortTransaction txnNumber parameter validation. + +Validates type acceptance for the txnNumber parameter. Per the MongoDB +documentation, txnNumber is typed as long (Int64). On a replica set, +Int64 values produce NotARetryableWriteCommand (50768) when there is no +matching transaction, while negative values produce InvalidOptions (72). +Non-Int64 numeric types and non-numeric types produce TypeMismatch. Null +is treated as omitted (falls through to NoSuchTransaction). +""" + +from __future__ import annotations + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import ( + COMMAND_FAILED_ERROR, + INVALID_OPTIONS_ERROR, + NOT_A_RETRYABLE_WRITE_COMMAND_ERROR, + TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = [pytest.mark.admin, pytest.mark.requires(transactions=True)] + + +# Property [txnNumber Int64 Acceptance]: Int64 values are accepted for txnNumber. +TXN_NUMBER_INT64_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "txn_number_int64_positive", + command={"abortTransaction": 1, "txnNumber": Int64(1)}, + error_code=NOT_A_RETRYABLE_WRITE_COMMAND_ERROR, + msg="abortTransaction should accept txnNumber:Int64(1) and fail", + ), + CommandTestCase( + "txn_number_int64_zero", + command={"abortTransaction": 1, "txnNumber": Int64(0)}, + error_code=NOT_A_RETRYABLE_WRITE_COMMAND_ERROR, + msg="abortTransaction should accept txnNumber:Int64(0) and fail", + ), + CommandTestCase( + "txn_number_int64_max", + command={"abortTransaction": 1, "txnNumber": Int64(9_223_372_036_854_775_807)}, + error_code=NOT_A_RETRYABLE_WRITE_COMMAND_ERROR, + msg="abortTransaction should accept txnNumber:Int64 max value", + ), + CommandTestCase( + "txn_number_int64_negative", + command={"abortTransaction": 1, "txnNumber": Int64(-1)}, + error_code=INVALID_OPTIONS_ERROR, + msg="abortTransaction should reject negative txnNumber with InvalidOptions", + ), +] + +# Property [txnNumber Type Strictness]: non-Int64 types are rejected with TypeMismatch. +TXN_NUMBER_TYPE_REJECTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "txn_number_int32", + command={"abortTransaction": 1, "txnNumber": 1}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject txnNumber:int32 as wrong type", + ), + CommandTestCase( + "txn_number_double_whole", + command={"abortTransaction": 1, "txnNumber": 1.0}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject txnNumber:double (whole) as wrong type", + ), + CommandTestCase( + "txn_number_double_fractional", + command={"abortTransaction": 1, "txnNumber": 1.5}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject txnNumber:double (fractional) as wrong type", + ), + CommandTestCase( + "txn_number_decimal128", + command={"abortTransaction": 1, "txnNumber": Decimal128("1")}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject txnNumber:Decimal128 as wrong type", + ), + CommandTestCase( + "txn_number_string", + command={"abortTransaction": 1, "txnNumber": "1"}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject txnNumber:string as wrong type", + ), + CommandTestCase( + "txn_number_bool", + command={"abortTransaction": 1, "txnNumber": True}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject txnNumber:bool as wrong type", + ), + CommandTestCase( + "txn_number_object", + command={"abortTransaction": 1, "txnNumber": {}}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject txnNumber:{} (object) as wrong type", + ), + CommandTestCase( + "txn_number_array", + command={"abortTransaction": 1, "txnNumber": []}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject txnNumber:[] (array) as wrong type", + ), +] + +# Property [txnNumber Null Handling]: null txnNumber is treated as omitted. +TXN_NUMBER_NULL_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "txn_number_null", + command={"abortTransaction": 1, "txnNumber": None}, + error_code=COMMAND_FAILED_ERROR, + msg="abortTransaction should treat txnNumber:null as omitted", + ), +] + +TXN_NUMBER_TESTS: list[CommandTestCase] = ( + TXN_NUMBER_INT64_TESTS + TXN_NUMBER_TYPE_REJECTION_TESTS + TXN_NUMBER_NULL_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(TXN_NUMBER_TESTS)) +def test_abortTransaction_txn_number_error(collection, test): + """Test abortTransaction txnNumber error cases.""" + result = execute_admin_command(collection, test.command) + assertFailureCode(result, test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_unrecognized_fields_error.py b/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_unrecognized_fields_error.py new file mode 100644 index 000000000..55b52b26b --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_unrecognized_fields_error.py @@ -0,0 +1,106 @@ +"""Tests for abortTransaction unrecognized field handling. + +Validates that the abortTransaction command rejects unknown fields. Covers +single unknown fields, multiple unknown fields, case-sensitive field names, +known fields from other commands, and dollar-prefixed fields. +""" + +from __future__ import annotations + +import pytest +from bson import Int64 + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import UNRECOGNIZED_COMMAND_FIELD_ERROR +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = [pytest.mark.admin, pytest.mark.requires(transactions=True)] + + +# Property [Unrecognized Field Rejection]: unknown fields are rejected. +UNRECOGNIZED_FIELD_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "unknown_single_field", + command={"abortTransaction": 1, "unknownField": 1}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="abortTransaction should reject single unknown field", + ), + CommandTestCase( + "unknown_multiple_fields", + command={"abortTransaction": 1, "foo": 1, "bar": 2}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="abortTransaction should reject multiple unknown fields", + ), +] + +# Property [Case Sensitivity]: field names are case-sensitive and wrong-case variants are rejected. +CASE_SENSITIVITY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "case_WriteConcern", + command={"abortTransaction": 1, "WriteConcern": {"w": 1}}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="abortTransaction should reject 'WriteConcern' (capital W) as unrecognized", + ), + CommandTestCase( + "case_Autocommit", + command={"abortTransaction": 1, "Autocommit": False}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="abortTransaction should reject 'Autocommit' (capital A) as unrecognized", + ), + CommandTestCase( + "case_TxnNumber", + command={"abortTransaction": 1, "TxnNumber": Int64(1)}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="abortTransaction should reject 'TxnNumber' (capital T) as unrecognized", + ), + CommandTestCase( + "case_Comment", + command={"abortTransaction": 1, "Comment": "test"}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="abortTransaction should reject 'Comment' (capital C) as unrecognized", + ), +] + +# Property [Foreign Field Rejection]: fields from other commands are rejected. +FOREIGN_FIELD_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "foreign_query", + command={"abortTransaction": 1, "query": {"x": 1}}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="abortTransaction should reject 'query' field from other commands", + ), + CommandTestCase( + "dollar_prefixed", + command={"abortTransaction": 1, "$unknown": 1}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="abortTransaction should reject dollar-prefixed unknown field", + ), +] + +# Property [writeConcern Unknown Sub-Field]: unknown writeConcern sub-fields are rejected. +WRITECONCERN_UNKNOWN_SUBFIELD_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "wc_unknown_subfield", + command={"abortTransaction": 1, "writeConcern": {"w": 1, "unknownOption": True}}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="abortTransaction should reject unknown writeConcern sub-field", + ), +] + +UNRECOGNIZED_TESTS: list[CommandTestCase] = ( + UNRECOGNIZED_FIELD_TESTS + + CASE_SENSITIVITY_TESTS + + FOREIGN_FIELD_TESTS + + WRITECONCERN_UNKNOWN_SUBFIELD_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(UNRECOGNIZED_TESTS)) +def test_abortTransaction_unrecognized_fields_error(collection, test): + """Test abortTransaction unrecognized field error cases.""" + result = execute_admin_command(collection, test.command) + assertFailureCode(result, test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_writeconcern.py b/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_writeconcern.py new file mode 100644 index 000000000..6b66e9024 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_writeconcern.py @@ -0,0 +1,194 @@ +"""Tests for abortTransaction writeConcern parameter acceptance in a real transaction. + +Validates that accepted writeConcern variants (document types, w sub-field +values, j sub-field values, wtimeout sub-field values, and combinations) +succeed when abortTransaction is issued inside an active transaction on a +replica set. +""" + +from __future__ import annotations + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertSuccessPartial +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = [pytest.mark.admin, pytest.mark.requires(transactions=True)] + +# Property [writeConcern Document Acceptance]: writeConcern accepts document values. +WRITECONCERN_ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "writeconcern_empty_doc", + command={"abortTransaction": 1, "writeConcern": {}}, + msg="abortTransaction should accept empty writeConcern document", + ), + CommandTestCase( + "writeconcern_null", + command={"abortTransaction": 1, "writeConcern": None}, + msg="abortTransaction should accept writeConcern:null", + ), + CommandTestCase( + "wc_combined_w_j_wtimeout", + command={ + "abortTransaction": 1, + "writeConcern": {"w": "majority", "j": True, "wtimeout": 10_000}, + }, + msg="abortTransaction should accept combined w + j + wtimeout", + ), + CommandTestCase( + "wc_w0_j_true", + command={"abortTransaction": 1, "writeConcern": {"w": 0, "j": True}}, + msg="abortTransaction should accept conflicting w:0 with j:true", + ), + CommandTestCase( + "wc_fsync_true", + command={"abortTransaction": 1, "writeConcern": {"fsync": True}}, + msg="abortTransaction should accept legacy writeConcern.fsync:true", + ), +] + +# Property [w Accepted Values]: w accepts int and string "majority" values. +W_ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "w_int32_one", + command={"abortTransaction": 1, "writeConcern": {"w": 1}}, + msg="abortTransaction should accept writeConcern.w:1", + ), + CommandTestCase( + "w_int32_zero", + command={"abortTransaction": 1, "writeConcern": {"w": 0}}, + msg="abortTransaction should accept writeConcern.w:0 (unacknowledged)", + ), + CommandTestCase( + "w_majority", + command={"abortTransaction": 1, "writeConcern": {"w": "majority"}}, + msg="abortTransaction should accept writeConcern.w:'majority'", + ), + CommandTestCase( + "w_int64", + command={"abortTransaction": 1, "writeConcern": {"w": Int64(1)}}, + msg="abortTransaction should accept writeConcern.w:Int64(1)", + ), + CommandTestCase( + "w_double_whole", + command={"abortTransaction": 1, "writeConcern": {"w": 1.0}}, + msg="abortTransaction should accept writeConcern.w:1.0", + ), + CommandTestCase( + "w_double_fractional", + command={"abortTransaction": 1, "writeConcern": {"w": 1.5}}, + msg="abortTransaction should accept writeConcern.w:1.5", + ), + CommandTestCase( + "w_decimal128", + command={"abortTransaction": 1, "writeConcern": {"w": Decimal128("1")}}, + msg="abortTransaction should accept writeConcern.w:Decimal128('1')", + ), +] + +# Property [j Accepted Values]: j accepts boolean and numeric types. +J_ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "j_bool_true", + command={"abortTransaction": 1, "writeConcern": {"j": True}}, + msg="abortTransaction should accept writeConcern.j:true", + ), + CommandTestCase( + "j_bool_false", + command={"abortTransaction": 1, "writeConcern": {"j": False}}, + msg="abortTransaction should accept writeConcern.j:false", + ), + CommandTestCase( + "j_int32_one", + command={"abortTransaction": 1, "writeConcern": {"j": 1}}, + msg="abortTransaction should accept writeConcern.j:1 (coerced to true)", + ), + CommandTestCase( + "j_int32_zero", + command={"abortTransaction": 1, "writeConcern": {"j": 0}}, + msg="abortTransaction should accept writeConcern.j:0 (coerced to false)", + ), + CommandTestCase( + "j_null", + command={"abortTransaction": 1, "writeConcern": {"j": None}}, + msg="abortTransaction should accept writeConcern.j:null", + ), +] + +# Property [wtimeout Accepted Values]: wtimeout accepts numeric types broadly. +WTIMEOUT_ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "wtimeout_int32_positive", + command={"abortTransaction": 1, "writeConcern": {"wtimeout": 1000}}, + msg="abortTransaction should accept writeConcern.wtimeout:1000", + ), + CommandTestCase( + "wtimeout_int32_zero", + command={"abortTransaction": 1, "writeConcern": {"wtimeout": 0}}, + msg="abortTransaction should accept writeConcern.wtimeout:0 (no timeout)", + ), + CommandTestCase( + "wtimeout_int64", + command={"abortTransaction": 1, "writeConcern": {"wtimeout": Int64(1000)}}, + msg="abortTransaction should accept writeConcern.wtimeout:Int64(1000)", + ), + CommandTestCase( + "wtimeout_double_whole", + command={"abortTransaction": 1, "writeConcern": {"wtimeout": 1000.0}}, + msg="abortTransaction should accept writeConcern.wtimeout:1000.0", + ), + CommandTestCase( + "wtimeout_negative", + command={"abortTransaction": 1, "writeConcern": {"wtimeout": -1}}, + msg="abortTransaction should accept writeConcern.wtimeout:-1", + ), + CommandTestCase( + "wtimeout_string", + command={"abortTransaction": 1, "writeConcern": {"wtimeout": "1000"}}, + msg="abortTransaction should accept writeConcern.wtimeout:'1000'", + ), + CommandTestCase( + "wtimeout_bool", + command={"abortTransaction": 1, "writeConcern": {"wtimeout": True}}, + msg="abortTransaction should accept writeConcern.wtimeout:true", + ), + CommandTestCase( + "wtimeout_null", + command={"abortTransaction": 1, "writeConcern": {"wtimeout": None}}, + msg="abortTransaction should accept writeConcern.wtimeout:null", + ), + CommandTestCase( + "wtimeout_object", + command={"abortTransaction": 1, "writeConcern": {"wtimeout": {}}}, + msg="abortTransaction should accept writeConcern.wtimeout:{}", + ), + CommandTestCase( + "wtimeout_array", + command={"abortTransaction": 1, "writeConcern": {"wtimeout": []}}, + msg="abortTransaction should accept writeConcern.wtimeout:[]", + ), +] + +WRITECONCERN_ACCEPTANCE_ALL_TESTS: list[CommandTestCase] = ( + WRITECONCERN_ACCEPTANCE_TESTS + + W_ACCEPTANCE_TESTS + + J_ACCEPTANCE_TESTS + + WTIMEOUT_ACCEPTANCE_TESTS +) + + +@pytest.mark.admin +@pytest.mark.parametrize("test", pytest_params(WRITECONCERN_ACCEPTANCE_ALL_TESTS)) +def test_abortTransaction_writeconcern(collection, test): + """Test abortTransaction writeConcern parameter acceptance in a transaction.""" + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + collection.insert_one({"_id": 1}, session=session) + result = execute_admin_command(collection, test.command, session=session) + assertSuccessPartial(result, {"ok": 1.0}, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_writeconcern_error.py b/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_writeconcern_error.py new file mode 100644 index 000000000..7ba972464 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_writeconcern_error.py @@ -0,0 +1,239 @@ +"""Tests for abortTransaction writeConcern parameter error cases. + +Validates that invalid writeConcern types and sub-field values are rejected +with the appropriate error codes. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import ( + COMMAND_FAILED_ERROR, + FAILED_TO_PARSE_ERROR, + TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = [pytest.mark.admin, pytest.mark.requires(transactions=True)] + +# Property [writeConcern Type Rejection]: non-document types are rejected with TypeMismatch. +WRITECONCERN_TYPE_REJECTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "writeconcern_string", + command={"abortTransaction": 1, "writeConcern": "majority"}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject writeConcern:string as wrong type", + ), + CommandTestCase( + "writeconcern_int32", + command={"abortTransaction": 1, "writeConcern": 1}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject writeConcern:int32 as wrong type", + ), + CommandTestCase( + "writeconcern_int64", + command={"abortTransaction": 1, "writeConcern": Int64(1)}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject writeConcern:Int64 as wrong type", + ), + CommandTestCase( + "writeconcern_double", + command={"abortTransaction": 1, "writeConcern": 1.0}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject writeConcern:double as wrong type", + ), + CommandTestCase( + "writeconcern_decimal128", + command={"abortTransaction": 1, "writeConcern": Decimal128("1")}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject writeConcern:Decimal128 as wrong type", + ), + CommandTestCase( + "writeconcern_bool_true", + command={"abortTransaction": 1, "writeConcern": True}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject writeConcern:true as wrong type", + ), + CommandTestCase( + "writeconcern_bool_false", + command={"abortTransaction": 1, "writeConcern": False}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject writeConcern:false as wrong type", + ), + CommandTestCase( + "writeconcern_array_empty", + command={"abortTransaction": 1, "writeConcern": []}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject writeConcern:[] as wrong type", + ), + CommandTestCase( + "writeconcern_array_nonempty", + command={"abortTransaction": 1, "writeConcern": [{"w": 1}]}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject writeConcern:[{w:1}] as wrong type", + ), + CommandTestCase( + "writeconcern_binary", + command={"abortTransaction": 1, "writeConcern": Binary(b"\x00")}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject writeConcern:Binary as wrong type", + ), + CommandTestCase( + "writeconcern_objectid", + command={"abortTransaction": 1, "writeConcern": ObjectId()}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject writeConcern:ObjectId as wrong type", + ), + CommandTestCase( + "writeconcern_datetime", + command={"abortTransaction": 1, "writeConcern": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject writeConcern:datetime as wrong type", + ), + CommandTestCase( + "writeconcern_regex", + command={"abortTransaction": 1, "writeConcern": Regex(".*")}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject writeConcern:Regex as wrong type", + ), + CommandTestCase( + "writeconcern_timestamp", + command={"abortTransaction": 1, "writeConcern": Timestamp(0, 0)}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject writeConcern:Timestamp as wrong type", + ), + CommandTestCase( + "writeconcern_code", + command={"abortTransaction": 1, "writeConcern": Code("function(){}")}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject writeConcern:Code as wrong type", + ), + CommandTestCase( + "writeconcern_minkey", + command={"abortTransaction": 1, "writeConcern": MinKey()}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject writeConcern:MinKey as wrong type", + ), + CommandTestCase( + "writeconcern_maxkey", + command={"abortTransaction": 1, "writeConcern": MaxKey()}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject writeConcern:MaxKey as wrong type", + ), +] + +# Property [w Invalid Values]: invalid w values are rejected with CommandFailed or FailedToParse. +W_INVALID_VALUE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "w_custom_tag", + command={"abortTransaction": 1, "writeConcern": {"w": "myTag"}}, + error_code=COMMAND_FAILED_ERROR, + msg="abortTransaction should reject writeConcern.w:'myTag' with CommandFailed", + ), + CommandTestCase( + "w_empty_string", + command={"abortTransaction": 1, "writeConcern": {"w": ""}}, + error_code=COMMAND_FAILED_ERROR, + msg="abortTransaction should reject writeConcern.w:'' with CommandFailed", + ), + CommandTestCase( + "w_null", + command={"abortTransaction": 1, "writeConcern": {"w": None}}, + error_code=COMMAND_FAILED_ERROR, + msg="abortTransaction should reject writeConcern.w:null with CommandFailed", + ), + CommandTestCase( + "w_negative_int", + command={"abortTransaction": 1, "writeConcern": {"w": -1}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="abortTransaction should reject writeConcern.w:-1 with FailedToParse", + ), + CommandTestCase( + "w_int32_max", + command={"abortTransaction": 1, "writeConcern": {"w": 2_147_483_647}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="abortTransaction should reject writeConcern.w:INT32_MAX with FailedToParse", + ), + CommandTestCase( + "w_bool_false", + command={"abortTransaction": 1, "writeConcern": {"w": False}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="abortTransaction should reject writeConcern.w:false with FailedToParse", + ), + CommandTestCase( + "w_bool_true", + command={"abortTransaction": 1, "writeConcern": {"w": True}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="abortTransaction should reject writeConcern.w:true with FailedToParse", + ), + CommandTestCase( + "w_object", + command={"abortTransaction": 1, "writeConcern": {"w": {}}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="abortTransaction should reject writeConcern.w:{} with FailedToParse", + ), + CommandTestCase( + "w_array", + command={"abortTransaction": 1, "writeConcern": {"w": []}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="abortTransaction should reject writeConcern.w:[] with FailedToParse", + ), +] + +# Property [j Type Rejection]: non-boolean non-numeric types are rejected with TypeMismatch. +J_TYPE_REJECTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "j_string", + command={"abortTransaction": 1, "writeConcern": {"j": "true"}}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject writeConcern.j:'true' as wrong type", + ), + CommandTestCase( + "j_object", + command={"abortTransaction": 1, "writeConcern": {"j": {}}}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject writeConcern.j:{} as wrong type", + ), + CommandTestCase( + "j_array", + command={"abortTransaction": 1, "writeConcern": {"j": []}}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject writeConcern.j:[] as wrong type", + ), +] + +# Property [wtimeout Overflow]: Int64 max value overflows and produces FailedToParse. +WTIMEOUT_OVERFLOW_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "wtimeout_int64_max", + command={ + "abortTransaction": 1, + "writeConcern": {"wtimeout": Int64(9_223_372_036_854_775_807)}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="abortTransaction should reject writeConcern.wtimeout:Int64 max with FailedToParse", + ), +] + +WRITECONCERN_ERROR_TESTS: list[CommandTestCase] = ( + WRITECONCERN_TYPE_REJECTION_TESTS + + W_INVALID_VALUE_TESTS + + J_TYPE_REJECTION_TESTS + + WTIMEOUT_OVERFLOW_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(WRITECONCERN_ERROR_TESTS)) +def test_abortTransaction_writeconcern_error(collection, test): + """Test abortTransaction writeConcern parameter error cases.""" + result = execute_admin_command(collection, test.command) + assertFailureCode(result, test.error_code, msg=test.msg) diff --git a/documentdb_tests/framework/error_codes.py b/documentdb_tests/framework/error_codes.py index 78a86b0c1..fff507521 100644 --- a/documentdb_tests/framework/error_codes.py +++ b/documentdb_tests/framework/error_codes.py @@ -40,6 +40,7 @@ COMMAND_NOT_SUPPORTED_ERROR = 115 DOCUMENT_VALIDATION_FAILURE_ERROR = 121 NOT_A_REPLICA_SET_ERROR = 123 +COMMAND_FAILED_ERROR = 125 CAPPED_POSITION_LOST_ERROR = 136 INCOMPATIBLE_COLLATION_VERSION_ERROR = 161 VIEW_DEPTH_LIMIT_ERROR = 165 @@ -53,8 +54,10 @@ INVALID_INDEX_SPEC_OPTION_ERROR = 197 INVALID_UUID_ERROR = 207 QUERY_FEATURE_NOT_ALLOWED = 224 +TRANSACTION_TOO_OLD_ERROR = 225 MAX_NESTED_SUB_PIPELINE_ERROR = 232 CONVERSION_FAILURE_ERROR = 241 +NO_SUCH_TRANSACTION_ERROR = 251 INVALID_RESUME_TOKEN_ERROR = 260 OPERATION_NOT_SUPPORTED_IN_TRANSACTION_ERROR = 263 CHANGE_STREAM_FATAL_ERROR = 280 @@ -368,6 +371,7 @@ TO_TYPE_ARITY_ERROR = 50723 CURSOR_SESSION_MISMATCH_ERROR = 50738 SUBSTR_NEGATIVE_START_ERROR = 50752 +NOT_A_RETRYABLE_WRITE_COMMAND_ERROR = 50768 KEYSTRING_UNKNOWN_TYPE_ERROR = 50811 CLUSTERED_NAN_DUPLICATE_ERROR = 50819 CLUSTERED_INFINITY_DUPLICATE_ERROR = 50826 From d162266c8a378d9808dc006bc11a09ba434b8036 Mon Sep 17 00:00:00 2001 From: "Alina (Xi) Li" <96995091+alinaliBQ@users.noreply.github.com> Date: Mon, 6 Jul 2026 18:07:45 -0700 Subject: [PATCH 05/35] commit Transaction tests (#560) Signed-off-by: Alina (Xi) Li Signed-off-by: RyanGarfinkel <113050972+RyanGarfinkel@users.noreply.github.com> --- .../commands/commitTransaction/__init__.py | 0 .../test_commitTransaction.py | 67 +++++ .../test_commitTransaction_atomicity.py | 28 ++ ...test_commitTransaction_autocommit_error.py | 116 ++++++++ .../test_commitTransaction_comment.py | 144 ++++++++++ .../test_commitTransaction_core_error.py | 110 ++++++++ ...commitTransaction_disallowed_operations.py | 245 +++++++++++++++++ .../test_commitTransaction_field_types.py | 176 ++++++++++++ .../test_commitTransaction_op_writeconcern.py | 29 ++ ..._commitTransaction_supported_operations.py | 217 +++++++++++++++ ...test_commitTransaction_txn_number_error.py | 131 +++++++++ ...itTransaction_unrecognized_fields_error.py | 106 ++++++++ .../test_commitTransaction_visibility.py | 40 +++ .../test_commitTransaction_write_conflict.py | 34 +++ .../test_commitTransaction_writeconcern.py | 192 +++++++++++++ ...st_commitTransaction_writeconcern_error.py | 256 ++++++++++++++++++ .../test_smoke_commitTransaction.py | 2 +- documentdb_tests/framework/error_codes.py | 1 + 18 files changed, 1893 insertions(+), 1 deletion(-) create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_atomicity.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_autocommit_error.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_comment.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_core_error.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_disallowed_operations.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_field_types.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_op_writeconcern.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_supported_operations.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_txn_number_error.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_unrecognized_fields_error.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_visibility.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_write_conflict.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_writeconcern.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_writeconcern_error.py diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/__init__.py b/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction.py b/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction.py new file mode 100644 index 000000000..34af5a44d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction.py @@ -0,0 +1,67 @@ +"""Tests for commitTransaction command success behavior. + +Each test drives the full transaction lifecycle inline (start session, start +transaction, run an operation, commit) so that setup -> act -> assert reads +top to bottom. These cover the fundamental commit behaviors: a committed write +is durable, an empty transaction commits, the response carries ok:1, and a +commit is retryable. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.framework.assertions import ( + assertNotError, + assertSuccess, + assertSuccessPartial, +) +from documentdb_tests.framework.executor import execute_admin_command, execute_command + +pytestmark = [pytest.mark.admin, pytest.mark.requires(transactions=True)] + + +def test_commitTransaction_persists_insert(collection): + """A committed insert is visible outside the transaction after commit.""" + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + collection.insert_one({"_id": 1, "x": "inserted"}, session=session) + session.commit_transaction() + readback = execute_command(collection, {"find": collection.name, "filter": {}}) + assertSuccess(readback, [{"_id": 1, "x": "inserted"}]) + + +def test_commitTransaction_empty(collection): + """Committing a transaction with no operations succeeds.""" + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + session.commit_transaction() + readback = execute_command(collection, {"find": collection.name, "filter": {}}) + assertNotError(readback, msg="commitTransaction on an empty transaction should not error") + + +def test_commitTransaction_response_ok(collection): + """The commitTransaction command response reports ok:1 on success.""" + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + collection.insert_one({"_id": 1}, session=session) + result = execute_admin_command(collection, {"commitTransaction": 1}, session=session) + assertSuccessPartial(result, {"ok": 1.0}) + + +def test_commitTransaction_is_retryable(collection): + """Re-sending commitTransaction after a successful commit returns ok:1. + + The committed state is retained for retryability, so the retry is a no-op + that succeeds rather than an error. + """ + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + collection.insert_one({"_id": 1}, session=session) + execute_admin_command(collection, {"commitTransaction": 1}, session=session) + retry = execute_admin_command(collection, {"commitTransaction": 1}, session=session) + assertSuccessPartial(retry, {"ok": 1.0}) diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_atomicity.py b/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_atomicity.py new file mode 100644 index 000000000..628e98aee --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_atomicity.py @@ -0,0 +1,28 @@ +"""Tests for cross-collection atomicity of a committed transaction. + +Writes to more than one collection in a single transaction are committed +atomically: after commit, the writes to the second collection are durable, +demonstrating the commit spans every collection touched in the transaction. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.executor import execute_command + +pytestmark = [pytest.mark.admin, pytest.mark.requires(transactions=True)] + + +def test_commit_persists_writes_across_collections(collection): + """Committing persists writes made to a second collection in the same txn.""" + client = collection.database.client + other = collection.database[f"{collection.name}_other"] + with client.start_session() as session: + session.start_transaction() + collection.insert_one({"_id": 1}, session=session) + other.insert_one({"_id": 2}, session=session) + session.commit_transaction() + readback = execute_command(collection, {"find": other.name, "filter": {}}) + assertSuccess(readback, [{"_id": 2}]) diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_autocommit_error.py b/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_autocommit_error.py new file mode 100644 index 000000000..47246e503 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_autocommit_error.py @@ -0,0 +1,116 @@ +"""Tests for commitTransaction autocommit parameter validation. + +Validates type and value acceptance for the autocommit parameter. Per the +MongoDB documentation, autocommit must be literal boolean false. Boolean true +produces InvalidOptions, non-boolean types produce TypeMismatch, and null is +treated as omitted (falls through to the no-transaction error). +""" + +from __future__ import annotations + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import ( + COMMAND_FAILED_ERROR, + INVALID_OPTIONS_ERROR, + TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = [pytest.mark.admin, pytest.mark.requires(transactions=True)] + + +# Property [autocommit Boolean Values]: autocommit accepts only boolean values. +AUTOCOMMIT_BOOLEAN_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "autocommit_bool_false", + command={"commitTransaction": 1, "autocommit": False}, + error_code=INVALID_OPTIONS_ERROR, + msg="commitTransaction should reject autocommit:false outside txn with InvalidOptions", + ), + CommandTestCase( + "autocommit_bool_true", + command={"commitTransaction": 1, "autocommit": True}, + error_code=INVALID_OPTIONS_ERROR, + msg="commitTransaction should reject autocommit:true with InvalidOptions", + ), +] + +# Property [autocommit Type Strictness]: non-boolean types are rejected with TypeMismatch. +AUTOCOMMIT_TYPE_REJECTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "autocommit_int32_zero", + command={"commitTransaction": 1, "autocommit": 0}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject autocommit:0 (int32) as wrong type", + ), + CommandTestCase( + "autocommit_int32_one", + command={"commitTransaction": 1, "autocommit": 1}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject autocommit:1 (int32) as wrong type", + ), + CommandTestCase( + "autocommit_int64_zero", + command={"commitTransaction": 1, "autocommit": Int64(0)}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject autocommit:Int64(0) as wrong type", + ), + CommandTestCase( + "autocommit_double_zero", + command={"commitTransaction": 1, "autocommit": 0.0}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject autocommit:0.0 as wrong type", + ), + CommandTestCase( + "autocommit_decimal128_zero", + command={"commitTransaction": 1, "autocommit": Decimal128("0")}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject autocommit:Decimal128('0') as wrong type", + ), + CommandTestCase( + "autocommit_string", + command={"commitTransaction": 1, "autocommit": "false"}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject autocommit:'false' (string) as wrong type", + ), + CommandTestCase( + "autocommit_object", + command={"commitTransaction": 1, "autocommit": {}}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject autocommit:{} (object) as wrong type", + ), + CommandTestCase( + "autocommit_array", + command={"commitTransaction": 1, "autocommit": []}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject autocommit:[] (array) as wrong type", + ), +] + +# Property [autocommit Null Handling]: null autocommit is treated as omitted. +AUTOCOMMIT_NULL_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "autocommit_null", + command={"commitTransaction": 1, "autocommit": None}, + error_code=COMMAND_FAILED_ERROR, + msg="commitTransaction should treat autocommit:null as omitted", + ), +] + +AUTOCOMMIT_TESTS: list[CommandTestCase] = ( + AUTOCOMMIT_BOOLEAN_TESTS + AUTOCOMMIT_TYPE_REJECTION_TESTS + AUTOCOMMIT_NULL_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(AUTOCOMMIT_TESTS)) +def test_commitTransaction_autocommit_error(collection, test): + """Test commitTransaction autocommit parameter validation.""" + result = execute_admin_command(collection, test.command) + assertFailureCode(result, test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_comment.py b/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_comment.py new file mode 100644 index 000000000..87f49d7ed --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_comment.py @@ -0,0 +1,144 @@ +"""Tests for commitTransaction comment parameter type acceptance in a real transaction. + +Validates that the comment parameter accepts any BSON type when +commitTransaction is issued inside an active transaction on a replica set. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertSuccessPartial +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = [pytest.mark.admin, pytest.mark.requires(transactions=True)] + +# Property [comment Type Acceptance]: comment accepts any BSON type. +COMMENT_TYPE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "comment_string", + command={"commitTransaction": 1, "comment": "test comment"}, + msg="commitTransaction should accept comment:string", + ), + CommandTestCase( + "comment_string_empty", + command={"commitTransaction": 1, "comment": ""}, + msg="commitTransaction should accept comment:empty string", + ), + CommandTestCase( + "comment_int32", + command={"commitTransaction": 1, "comment": 42}, + msg="commitTransaction should accept comment:int32", + ), + CommandTestCase( + "comment_int64", + command={"commitTransaction": 1, "comment": Int64(42)}, + msg="commitTransaction should accept comment:Int64", + ), + CommandTestCase( + "comment_double", + command={"commitTransaction": 1, "comment": 3.14}, + msg="commitTransaction should accept comment:double", + ), + CommandTestCase( + "comment_decimal128", + command={"commitTransaction": 1, "comment": Decimal128("1.5")}, + msg="commitTransaction should accept comment:Decimal128", + ), + CommandTestCase( + "comment_bool_true", + command={"commitTransaction": 1, "comment": True}, + msg="commitTransaction should accept comment:true", + ), + CommandTestCase( + "comment_bool_false", + command={"commitTransaction": 1, "comment": False}, + msg="commitTransaction should accept comment:false", + ), + CommandTestCase( + "comment_null", + command={"commitTransaction": 1, "comment": None}, + msg="commitTransaction should accept comment:null", + ), + CommandTestCase( + "comment_object", + command={"commitTransaction": 1, "comment": {"key": "value"}}, + msg="commitTransaction should accept comment:object", + ), + CommandTestCase( + "comment_object_empty", + command={"commitTransaction": 1, "comment": {}}, + msg="commitTransaction should accept comment:empty object", + ), + CommandTestCase( + "comment_array", + command={"commitTransaction": 1, "comment": [1, 2, 3]}, + msg="commitTransaction should accept comment:array", + ), + CommandTestCase( + "comment_array_empty", + command={"commitTransaction": 1, "comment": []}, + msg="commitTransaction should accept comment:empty array", + ), + CommandTestCase( + "comment_objectid", + command={"commitTransaction": 1, "comment": ObjectId()}, + msg="commitTransaction should accept comment:ObjectId", + ), + CommandTestCase( + "comment_datetime", + command={ + "commitTransaction": 1, + "comment": datetime(2024, 1, 1, tzinfo=timezone.utc), + }, + msg="commitTransaction should accept comment:datetime", + ), + CommandTestCase( + "comment_binary", + command={"commitTransaction": 1, "comment": Binary(b"\x00")}, + msg="commitTransaction should accept comment:Binary", + ), + CommandTestCase( + "comment_regex", + command={"commitTransaction": 1, "comment": Regex(".*")}, + msg="commitTransaction should accept comment:Regex", + ), + CommandTestCase( + "comment_timestamp", + command={"commitTransaction": 1, "comment": Timestamp(0, 0)}, + msg="commitTransaction should accept comment:Timestamp", + ), + CommandTestCase( + "comment_minkey", + command={"commitTransaction": 1, "comment": MinKey()}, + msg="commitTransaction should accept comment:MinKey", + ), + CommandTestCase( + "comment_maxkey", + command={"commitTransaction": 1, "comment": MaxKey()}, + msg="commitTransaction should accept comment:MaxKey", + ), + CommandTestCase( + "comment_code", + command={"commitTransaction": 1, "comment": Code("function(){}")}, + msg="commitTransaction should accept comment:Code", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(COMMENT_TYPE_TESTS)) +def test_commitTransaction_comment(collection, test): + """Test commitTransaction comment parameter type acceptance in a transaction.""" + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + collection.insert_one({"_id": 1}, session=session) + result = execute_admin_command(collection, test.command, session=session) + assertSuccessPartial(result, {"ok": 1.0}, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_core_error.py b/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_core_error.py new file mode 100644 index 000000000..f18a720b8 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_core_error.py @@ -0,0 +1,110 @@ +"""Tests for commitTransaction command core behavior. + +Validates fundamental command behavior including the no-transaction error, +admin database requirement, and parameter interactions. +""" + +from __future__ import annotations + +import pytest +from bson import Binary, Int64 + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import ( + COMMAND_FAILED_ERROR, + INVALID_OPTIONS_ERROR, + NOT_A_RETRYABLE_WRITE_COMMAND_ERROR, + TRANSACTION_TOO_OLD_ERROR, + UNAUTHORIZED_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = [pytest.mark.admin, pytest.mark.requires(transactions=True)] + + +# Property [No-Transaction Error]: commitTransaction outside a transaction fails. +CORE_NO_TRANSACTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "no_transaction_basic", + command={"commitTransaction": 1}, + error_code=COMMAND_FAILED_ERROR, + msg="commitTransaction outside a transaction should fail", + ), +] + +# Property [Parameter Acceptance]: all valid parameters combined are syntactically accepted. +CORE_PARAMETER_ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "all_valid_params_no_parse_error", + command={ + "commitTransaction": 1, + "autocommit": False, + "txnNumber": Int64(1), + "writeConcern": {"w": "majority", "j": True, "wtimeout": 10_000}, + "comment": "full commit", + }, + error_code=TRANSACTION_TOO_OLD_ERROR, + msg="commitTransaction with all valid params should not produce a parsing error", + ), +] + +# Property [Parameter Interactions]: combinations of valid parameters behave correctly. +CORE_PARAMETER_INTERACTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "interaction_autocommit_only", + command={"commitTransaction": 1, "autocommit": False}, + error_code=INVALID_OPTIONS_ERROR, + msg="commitTransaction with autocommit:false only should fail with InvalidOptions", + ), + CommandTestCase( + "interaction_txn_number_only", + command={"commitTransaction": 1, "txnNumber": Int64(1)}, + error_code=NOT_A_RETRYABLE_WRITE_COMMAND_ERROR, + msg="commitTransaction with txnNumber only should fail with NotARetryableWriteCommand", + ), + CommandTestCase( + "interaction_autocommit_txn_number", + command={"commitTransaction": 1, "autocommit": False, "txnNumber": Int64(1)}, + error_code=TRANSACTION_TOO_OLD_ERROR, + msg="commitTransaction with autocommit + txnNumber should fail with TransactionTooOld", + ), + CommandTestCase( + "interaction_lsid", + command={"commitTransaction": 1, "lsid": {"id": Binary(b"\x00" * 16, 4)}}, + error_code=COMMAND_FAILED_ERROR, + msg="commitTransaction with explicit lsid should accept the field", + ), +] + +CORE_TESTS: list[CommandTestCase] = ( + CORE_NO_TRANSACTION_TESTS + CORE_PARAMETER_ACCEPTANCE_TESTS + CORE_PARAMETER_INTERACTION_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(CORE_TESTS)) +def test_commitTransaction_core_error(collection, test): + """Test commitTransaction core behavior.""" + result = execute_admin_command(collection, test.command) + assertFailureCode(result, test.error_code, msg=test.msg) + + +# Property [Admin Database Requirement]: commitTransaction must run against the admin database. +ADMIN_DB_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "non_admin_database", + command={"commitTransaction": 1}, + error_code=UNAUTHORIZED_ERROR, + msg="commitTransaction on a non-admin database should fail with Unauthorized", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(ADMIN_DB_TESTS)) +def test_commitTransaction_admin_db_required_error(collection, test): + """Test commitTransaction requires admin database.""" + result = execute_command(collection, test.command) + assertFailureCode(result, test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_disallowed_operations.py b/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_disallowed_operations.py new file mode 100644 index 000000000..2492910d3 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_disallowed_operations.py @@ -0,0 +1,245 @@ +"""Tests for operations that are not supported inside a transaction. + +Which operation types may run inside a transaction is its own concern (kept out +of the behavioral files). A range of commands and aggregation stages are +rejected with OperationNotSupportedInTransaction when issued inside an active +transaction, as are writes to a capped collection or to a system database. Each +test issues the operation in the transaction and asserts the rejection; the +session is closed by its ``with`` block. +""" + +from __future__ import annotations + +import pytest +from bson import Int64 + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import OPERATION_NOT_SUPPORTED_IN_TRANSACTION_ERROR +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = [pytest.mark.admin, pytest.mark.requires(transactions=True)] + + +# Commands rejected inside a transaction with OperationNotSupportedInTransaction. +DISALLOWED_COMMANDS: list[CommandTestCase] = [ + CommandTestCase( + "count", + command=lambda ctx: {"count": ctx.collection}, + error_code=OPERATION_NOT_SUPPORTED_IN_TRANSACTION_ERROR, + msg="count command is not supported inside a transaction", + ), + CommandTestCase( + "listCollections", + command={"listCollections": 1}, + error_code=OPERATION_NOT_SUPPORTED_IN_TRANSACTION_ERROR, + msg="listCollections is not supported inside a transaction", + ), + CommandTestCase( + "listIndexes", + command=lambda ctx: {"listIndexes": ctx.collection}, + error_code=OPERATION_NOT_SUPPORTED_IN_TRANSACTION_ERROR, + msg="listIndexes is not supported inside a transaction", + ), + CommandTestCase( + "explain", + command=lambda ctx: {"explain": {"find": ctx.collection}}, + error_code=OPERATION_NOT_SUPPORTED_IN_TRANSACTION_ERROR, + msg="explain is not supported inside a transaction", + ), + CommandTestCase( + "createUser", + command={"createUser": "commit_txn_user", "pwd": "commit_txn_pwd", "roles": []}, + error_code=OPERATION_NOT_SUPPORTED_IN_TRANSACTION_ERROR, + msg="createUser is not supported inside a transaction", + ), + CommandTestCase( + "getParameter", + command={"getParameter": 1, "logLevel": 1}, + error_code=OPERATION_NOT_SUPPORTED_IN_TRANSACTION_ERROR, + msg="getParameter is not supported inside a transaction", + ), + CommandTestCase( + "setParameter", + command={"setParameter": 1, "logLevel": 0}, + error_code=OPERATION_NOT_SUPPORTED_IN_TRANSACTION_ERROR, + msg="setParameter is not supported inside a transaction", + ), + CommandTestCase( + "killCursors", + command=lambda ctx: {"killCursors": ctx.collection, "cursors": [Int64(0)]}, + error_code=OPERATION_NOT_SUPPORTED_IN_TRANSACTION_ERROR, + msg="killCursors is not supported as the first operation in a transaction", + ), + CommandTestCase( + "hello", + command={"hello": 1}, + error_code=OPERATION_NOT_SUPPORTED_IN_TRANSACTION_ERROR, + msg="hello is not supported as the first operation in a transaction", + ), + CommandTestCase( + "buildInfo", + command={"buildInfo": 1}, + error_code=OPERATION_NOT_SUPPORTED_IN_TRANSACTION_ERROR, + msg="buildInfo is not supported as the first operation in a transaction", + ), + CommandTestCase( + "connectionStatus", + command={"connectionStatus": 1}, + error_code=OPERATION_NOT_SUPPORTED_IN_TRANSACTION_ERROR, + msg="connectionStatus is not supported as the first operation in a transaction", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(DISALLOWED_COMMANDS)) +def test_command_disallowed_in_transaction(collection, test): + """Commands not supported inside a transaction are rejected.""" + collection.insert_one({"_id": 1}) + ctx = CommandContext.from_collection(collection) + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + result = execute_command(collection, test.build_command(ctx), session=session) + assertFailureCode(result, test.error_code, msg=test.msg) + + +# Aggregation stages rejected inside a transaction (run against the fixture +# collection via the aggregate command). +DISALLOWED_AGGREGATION_STAGES: list[CommandTestCase] = [ + CommandTestCase( + "out", + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [{"$out": f"{ctx.collection}_out"}], + "cursor": {}, + }, + error_code=OPERATION_NOT_SUPPORTED_IN_TRANSACTION_ERROR, + msg="$out is not supported inside a transaction", + ), + CommandTestCase( + "merge", + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [{"$merge": {"into": f"{ctx.collection}_merge"}}], + "cursor": {}, + }, + error_code=OPERATION_NOT_SUPPORTED_IN_TRANSACTION_ERROR, + msg="$merge is not supported inside a transaction", + ), + CommandTestCase( + "unionWith", + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [{"$unionWith": ctx.collection}], + "cursor": {}, + }, + error_code=OPERATION_NOT_SUPPORTED_IN_TRANSACTION_ERROR, + msg="$unionWith is not supported inside a transaction", + ), + CommandTestCase( + "collStats", + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [{"$collStats": {"count": {}}}], + "cursor": {}, + }, + error_code=OPERATION_NOT_SUPPORTED_IN_TRANSACTION_ERROR, + msg="$collStats is not supported inside a transaction", + ), + CommandTestCase( + "indexStats", + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [{"$indexStats": {}}], + "cursor": {}, + }, + error_code=OPERATION_NOT_SUPPORTED_IN_TRANSACTION_ERROR, + msg="$indexStats is not supported inside a transaction", + ), + CommandTestCase( + "planCacheStats", + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [{"$planCacheStats": {}}], + "cursor": {}, + }, + error_code=OPERATION_NOT_SUPPORTED_IN_TRANSACTION_ERROR, + msg="$planCacheStats is not supported inside a transaction", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(DISALLOWED_AGGREGATION_STAGES)) +def test_aggregation_stage_disallowed_in_transaction(collection, test): + """Aggregation stages not supported inside a transaction are rejected.""" + collection.insert_one({"_id": 1}) + ctx = CommandContext.from_collection(collection) + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + result = execute_command(collection, test.build_command(ctx), session=session) + assertFailureCode(result, test.error_code, msg=test.msg) + + +# Admin-level (database) aggregation stages rejected inside a transaction. +DISALLOWED_ADMIN_STAGES: list[CommandTestCase] = [ + CommandTestCase( + "currentOp", + command={"aggregate": 1, "pipeline": [{"$currentOp": {}}], "cursor": {}}, + error_code=OPERATION_NOT_SUPPORTED_IN_TRANSACTION_ERROR, + msg="$currentOp is not supported inside a transaction", + ), + CommandTestCase( + "listLocalSessions", + command={"aggregate": 1, "pipeline": [{"$listLocalSessions": {}}], "cursor": {}}, + error_code=OPERATION_NOT_SUPPORTED_IN_TRANSACTION_ERROR, + msg="$listLocalSessions is not supported inside a transaction", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(DISALLOWED_ADMIN_STAGES)) +def test_admin_aggregation_stage_disallowed_in_transaction(collection, test): + """Admin-level aggregation stages not supported inside a transaction are rejected.""" + collection.insert_one({"_id": 1}) + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + result = execute_admin_command(collection, test.command, session=session) + assertFailureCode(result, test.error_code, msg=test.msg) + + +def test_write_to_capped_collection_disallowed_in_transaction(collection): + """Writing to a capped collection inside a transaction is not supported.""" + capped = collection.database.create_collection( + f"{collection.name}_capped", capped=True, size=4096 + ) + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + result = execute_command( + collection, + {"insert": capped.name, "documents": [{"_id": 1}]}, + session=session, + ) + assertFailureCode(result, OPERATION_NOT_SUPPORTED_IN_TRANSACTION_ERROR) + + +@pytest.mark.parametrize("system_db_name", ["config", "local"]) +def test_write_to_system_database_disallowed_in_transaction(collection, system_db_name): + """Writing to a system database inside a transaction is not supported.""" + client = collection.database.client + system_collection = client[system_db_name][f"{collection.name}_probe"] + with client.start_session() as session: + session.start_transaction() + result = execute_command( + system_collection, + {"insert": system_collection.name, "documents": [{"_id": 1}]}, + session=session, + ) + assertFailureCode(result, OPERATION_NOT_SUPPORTED_IN_TRANSACTION_ERROR) diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_field_types.py b/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_field_types.py new file mode 100644 index 000000000..624c5284e --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_field_types.py @@ -0,0 +1,176 @@ +"""Tests for commitTransaction command field type acceptance in a real transaction. + +Validates that the commitTransaction command's primary field accepts all BSON +types when issued inside an active transaction on a replica set. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertSuccessPartial +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = [pytest.mark.admin, pytest.mark.requires(transactions=True)] + +# Property [Field Type Acceptance]: the command field accepts any BSON type. +FIELD_TYPE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "field_int32_positive", + command={"commitTransaction": 1}, + msg="commitTransaction should accept int32 positive value", + ), + CommandTestCase( + "field_int32_negative", + command={"commitTransaction": -1}, + msg="commitTransaction should accept int32 negative value", + ), + CommandTestCase( + "field_int32_zero", + command={"commitTransaction": 0}, + msg="commitTransaction should accept int32 zero value", + ), + CommandTestCase( + "field_int64", + command={"commitTransaction": Int64(1)}, + msg="commitTransaction should accept int64 value", + ), + CommandTestCase( + "field_int64_max", + command={"commitTransaction": Int64(9_223_372_036_854_775_807)}, + msg="commitTransaction should accept int64 max value", + ), + CommandTestCase( + "field_double", + command={"commitTransaction": 1.0}, + msg="commitTransaction should accept double value", + ), + CommandTestCase( + "field_double_negative", + command={"commitTransaction": -1.0}, + msg="commitTransaction should accept negative double value", + ), + CommandTestCase( + "field_double_zero", + command={"commitTransaction": 0.0}, + msg="commitTransaction should accept double zero value", + ), + CommandTestCase( + "field_decimal128", + command={"commitTransaction": Decimal128("1")}, + msg="commitTransaction should accept Decimal128 value", + ), + CommandTestCase( + "field_bool_true", + command={"commitTransaction": True}, + msg="commitTransaction should accept bool true value", + ), + CommandTestCase( + "field_bool_false", + command={"commitTransaction": False}, + msg="commitTransaction should accept bool false value", + ), + CommandTestCase( + "field_nan", + command={"commitTransaction": float("nan")}, + msg="commitTransaction should accept NaN value", + ), + CommandTestCase( + "field_infinity", + command={"commitTransaction": float("inf")}, + msg="commitTransaction should accept Infinity value", + ), + CommandTestCase( + "field_string", + command={"commitTransaction": "commitTransaction"}, + msg="commitTransaction should accept string value", + ), + CommandTestCase( + "field_string_empty", + command={"commitTransaction": ""}, + msg="commitTransaction should accept empty string value", + ), + CommandTestCase( + "field_null", + command={"commitTransaction": None}, + msg="commitTransaction should accept null value", + ), + CommandTestCase( + "field_object_empty", + command={"commitTransaction": {}}, + msg="commitTransaction should accept empty object value", + ), + CommandTestCase( + "field_object_nonempty", + command={"commitTransaction": {"key": "value"}}, + msg="commitTransaction should accept non-empty object value", + ), + CommandTestCase( + "field_array_empty", + command={"commitTransaction": []}, + msg="commitTransaction should accept empty array value", + ), + CommandTestCase( + "field_array_nonempty", + command={"commitTransaction": [1, 2]}, + msg="commitTransaction should accept non-empty array value", + ), + CommandTestCase( + "field_binary", + command={"commitTransaction": Binary(b"\x00")}, + msg="commitTransaction should accept Binary value", + ), + CommandTestCase( + "field_objectid", + command={"commitTransaction": ObjectId()}, + msg="commitTransaction should accept ObjectId value", + ), + CommandTestCase( + "field_datetime", + command={"commitTransaction": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + msg="commitTransaction should accept datetime value", + ), + CommandTestCase( + "field_regex", + command={"commitTransaction": Regex(".*")}, + msg="commitTransaction should accept Regex value", + ), + CommandTestCase( + "field_timestamp", + command={"commitTransaction": Timestamp(0, 0)}, + msg="commitTransaction should accept Timestamp value", + ), + CommandTestCase( + "field_code", + command={"commitTransaction": Code("function(){}")}, + msg="commitTransaction should accept Code value", + ), + CommandTestCase( + "field_minkey", + command={"commitTransaction": MinKey()}, + msg="commitTransaction should accept MinKey value", + ), + CommandTestCase( + "field_maxkey", + command={"commitTransaction": MaxKey()}, + msg="commitTransaction should accept MaxKey value", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(FIELD_TYPE_TESTS)) +def test_commitTransaction_field_types(collection, test): + """Test commitTransaction command field type acceptance in a transaction.""" + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + collection.insert_one({"_id": 1}, session=session) + result = execute_admin_command(collection, test.command, session=session) + assertSuccessPartial(result, {"ok": 1.0}, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_op_writeconcern.py b/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_op_writeconcern.py new file mode 100644 index 000000000..75740fff0 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_op_writeconcern.py @@ -0,0 +1,29 @@ +"""Test that an explicit writeConcern on an operation inside a transaction is rejected. + +Per the MongoDB docs, setting a write concern on the individual write operations +inside a transaction returns an error; the write concern is set on the +transaction as a whole (on commit) instead. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import INVALID_OPTIONS_ERROR +from documentdb_tests.framework.executor import execute_command + +pytestmark = [pytest.mark.admin, pytest.mark.requires(transactions=True)] + + +def test_op_level_write_concern_rejected_in_transaction(collection): + """An explicit writeConcern on a write operation inside a transaction is rejected.""" + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + result = execute_command( + collection, + {"insert": collection.name, "documents": [{"_id": 1}], "writeConcern": {"w": 1}}, + session=session, + ) + assertFailureCode(result, INVALID_OPTIONS_ERROR) diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_supported_operations.py b/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_supported_operations.py new file mode 100644 index 000000000..60e72f74c --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_supported_operations.py @@ -0,0 +1,217 @@ +"""Tests for operations that are supported inside a transaction. + +Reads (find), aggregation (including ``$count``, the supported counterpart to +the disallowed ``count`` command), writes (update, delete), and a range of other +CRUD/administration commands and query operators are accepted inside a +transaction. Each test runs the operation in the transaction and commits; +acceptance is asserted via the ``ok: 1`` command response (contrast the +disallowed-operations file, where the same operations would be rejected). +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.framework.assertions import assertSuccess, assertSuccessPartial +from documentdb_tests.framework.executor import execute_admin_command, execute_command + +pytestmark = [pytest.mark.admin, pytest.mark.requires(transactions=True)] + + +def test_find_runs_in_transaction(collection): + """A find issued inside a transaction returns the collection's documents.""" + collection.insert_one({"_id": 1}) + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + result = execute_command( + collection, {"find": collection.name, "filter": {}}, session=session + ) + session.commit_transaction() + assertSuccess(result, [{"_id": 1}]) + + +def test_aggregate_count_runs_in_transaction(collection): + """An aggregate with $count runs inside a transaction and returns the count.""" + collection.insert_many([{"_id": 1}, {"_id": 2}]) + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + result = execute_command( + collection, + {"aggregate": collection.name, "pipeline": [{"$count": "n"}], "cursor": {}}, + session=session, + ) + session.commit_transaction() + assertSuccess(result, [{"n": 2}]) + + +def test_update_runs_in_transaction(collection): + """An update issued inside a transaction is durable after commit.""" + collection.insert_one({"_id": 1, "x": "before"}) + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + collection.update_one({"_id": 1}, {"$set": {"x": "after"}}, session=session) + session.commit_transaction() + readback = execute_command(collection, {"find": collection.name, "filter": {}}) + assertSuccess(readback, [{"_id": 1, "x": "after"}]) + + +def test_delete_runs_in_transaction(collection): + """A delete issued inside a transaction is durable after commit.""" + collection.insert_one({"_id": 1}) + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + collection.delete_one({"_id": 1}, session=session) + session.commit_transaction() + readback = execute_command(collection, {"find": collection.name, "filter": {}}) + assertSuccess(readback, []) + + +def test_distinct_runs_in_transaction(collection): + """The distinct command is accepted inside a transaction.""" + collection.insert_many([{"_id": 1, "x": 1}, {"_id": 2, "x": 2}]) + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + result = execute_command( + collection, {"distinct": collection.name, "key": "x"}, session=session + ) + session.commit_transaction() + assertSuccessPartial(result, {"ok": 1.0}) + + +def test_findAndModify_runs_in_transaction(collection): + """The findAndModify command is accepted inside a transaction.""" + collection.insert_one({"_id": 1, "x": "before"}) + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + result = execute_command( + collection, + { + "findAndModify": collection.name, + "query": {"_id": 1}, + "update": {"$set": {"x": "after"}}, + }, + session=session, + ) + session.commit_transaction() + assertSuccessPartial(result, {"ok": 1.0}) + + +def test_bulkWrite_runs_in_transaction(collection): + """The bulkWrite command is accepted inside a transaction.""" + client = collection.database.client + namespace = f"{collection.database.name}.{collection.name}" + with client.start_session() as session: + session.start_transaction() + result = execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "nsInfo": [{"ns": namespace}], + }, + session=session, + ) + session.commit_transaction() + assertSuccessPartial(result, {"ok": 1.0}) + + +def test_create_collection_runs_in_transaction(collection): + """Creating a collection inside a transaction is accepted.""" + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + result = execute_command(collection, {"create": f"{collection.name}_new"}, session=session) + session.commit_transaction() + assertSuccessPartial(result, {"ok": 1.0}) + + +def test_createIndexes_on_new_collection_runs_in_transaction(collection): + """createIndexes is accepted on a collection created earlier in the same transaction.""" + client = collection.database.client + new_collection = f"{collection.name}_new" + with client.start_session() as session: + session.start_transaction() + execute_command(collection, {"create": new_collection}, session=session) + result = execute_command( + collection, + {"createIndexes": new_collection, "indexes": [{"key": {"x": 1}, "name": "x_1"}]}, + session=session, + ) + session.commit_transaction() + assertSuccessPartial(result, {"ok": 1.0}) + + +def test_near_query_runs_in_transaction(collection): + """A $near query runs inside a transaction (with a 2dsphere index).""" + collection.create_index([("loc", "2dsphere")]) + collection.insert_one({"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}) + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + result = execute_command( + collection, + { + "find": collection.name, + "filter": { + "loc": {"$near": {"$geometry": {"type": "Point", "coordinates": [0, 0]}}} + }, + }, + session=session, + ) + session.commit_transaction() + assertSuccessPartial(result, {"ok": 1.0}) + + +def test_nearSphere_query_runs_in_transaction(collection): + """A $nearSphere query runs inside a transaction (with a 2dsphere index).""" + collection.create_index([("loc", "2dsphere")]) + collection.insert_one({"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}) + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + result = execute_command( + collection, + { + "find": collection.name, + "filter": { + "loc": {"$nearSphere": {"$geometry": {"type": "Point", "coordinates": [0, 0]}}} + }, + }, + session=session, + ) + session.commit_transaction() + assertSuccessPartial(result, {"ok": 1.0}) + + +def test_geoNear_stage_runs_in_transaction(collection): + """A $geoNear aggregation stage runs inside a transaction (with a 2dsphere index).""" + collection.create_index([("loc", "2dsphere")]) + collection.insert_one({"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}) + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$geoNear": { + "near": {"type": "Point", "coordinates": [0, 0]}, + "distanceField": "d", + "spherical": True, + } + } + ], + "cursor": {}, + }, + session=session, + ) + session.commit_transaction() + assertSuccessPartial(result, {"ok": 1.0}) diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_txn_number_error.py b/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_txn_number_error.py new file mode 100644 index 000000000..135bd9470 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_txn_number_error.py @@ -0,0 +1,131 @@ +"""Tests for commitTransaction txnNumber parameter validation. + +Validates type acceptance for the txnNumber parameter. Per the MongoDB +documentation, txnNumber is typed as long (Int64). On a replica set, +Int64 values produce NotARetryableWriteCommand (50768) when there is no +matching transaction, while negative values produce InvalidOptions (72). +Non-Int64 numeric types and non-numeric types produce TypeMismatch. Null +is treated as omitted (falls through to the no-transaction error). +""" + +from __future__ import annotations + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import ( + COMMAND_FAILED_ERROR, + INVALID_OPTIONS_ERROR, + NOT_A_RETRYABLE_WRITE_COMMAND_ERROR, + TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = [pytest.mark.admin, pytest.mark.requires(transactions=True)] + + +# Property [txnNumber Int64 Acceptance]: Int64 values are accepted for txnNumber. +TXN_NUMBER_INT64_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "txn_number_int64_positive", + command={"commitTransaction": 1, "txnNumber": Int64(1)}, + error_code=NOT_A_RETRYABLE_WRITE_COMMAND_ERROR, + msg="commitTransaction should accept txnNumber:Int64(1) and fail", + ), + CommandTestCase( + "txn_number_int64_zero", + command={"commitTransaction": 1, "txnNumber": Int64(0)}, + error_code=NOT_A_RETRYABLE_WRITE_COMMAND_ERROR, + msg="commitTransaction should accept txnNumber:Int64(0) and fail", + ), + CommandTestCase( + "txn_number_int64_max", + command={"commitTransaction": 1, "txnNumber": Int64(9_223_372_036_854_775_807)}, + error_code=NOT_A_RETRYABLE_WRITE_COMMAND_ERROR, + msg="commitTransaction should accept txnNumber:Int64 max value", + ), + CommandTestCase( + "txn_number_int64_negative", + command={"commitTransaction": 1, "txnNumber": Int64(-1)}, + error_code=INVALID_OPTIONS_ERROR, + msg="commitTransaction should reject negative txnNumber with InvalidOptions", + ), +] + +# Property [txnNumber Type Strictness]: non-Int64 types are rejected with TypeMismatch. +TXN_NUMBER_TYPE_REJECTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "txn_number_int32", + command={"commitTransaction": 1, "txnNumber": 1}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject txnNumber:int32 as wrong type", + ), + CommandTestCase( + "txn_number_double_whole", + command={"commitTransaction": 1, "txnNumber": 1.0}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject txnNumber:double (whole) as wrong type", + ), + CommandTestCase( + "txn_number_double_fractional", + command={"commitTransaction": 1, "txnNumber": 1.5}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject txnNumber:double (fractional) as wrong type", + ), + CommandTestCase( + "txn_number_decimal128", + command={"commitTransaction": 1, "txnNumber": Decimal128("1")}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject txnNumber:Decimal128 as wrong type", + ), + CommandTestCase( + "txn_number_string", + command={"commitTransaction": 1, "txnNumber": "1"}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject txnNumber:string as wrong type", + ), + CommandTestCase( + "txn_number_bool", + command={"commitTransaction": 1, "txnNumber": True}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject txnNumber:bool as wrong type", + ), + CommandTestCase( + "txn_number_object", + command={"commitTransaction": 1, "txnNumber": {}}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject txnNumber:{} (object) as wrong type", + ), + CommandTestCase( + "txn_number_array", + command={"commitTransaction": 1, "txnNumber": []}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject txnNumber:[] (array) as wrong type", + ), +] + +# Property [txnNumber Null Handling]: null txnNumber is treated as omitted. +TXN_NUMBER_NULL_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "txn_number_null", + command={"commitTransaction": 1, "txnNumber": None}, + error_code=COMMAND_FAILED_ERROR, + msg="commitTransaction should treat txnNumber:null as omitted", + ), +] + +TXN_NUMBER_TESTS: list[CommandTestCase] = ( + TXN_NUMBER_INT64_TESTS + TXN_NUMBER_TYPE_REJECTION_TESTS + TXN_NUMBER_NULL_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(TXN_NUMBER_TESTS)) +def test_commitTransaction_txn_number_error(collection, test): + """Test commitTransaction txnNumber parameter validation.""" + result = execute_admin_command(collection, test.command) + assertFailureCode(result, test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_unrecognized_fields_error.py b/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_unrecognized_fields_error.py new file mode 100644 index 000000000..0c591208a --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_unrecognized_fields_error.py @@ -0,0 +1,106 @@ +"""Tests for commitTransaction unrecognized field handling. + +Validates that the commitTransaction command rejects unknown fields. Covers +single unknown fields, multiple unknown fields, case-sensitive field names, +known fields from other commands, and dollar-prefixed fields. +""" + +from __future__ import annotations + +import pytest +from bson import Int64 + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import UNRECOGNIZED_COMMAND_FIELD_ERROR +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = [pytest.mark.admin, pytest.mark.requires(transactions=True)] + + +# Property [Unrecognized Field Rejection]: unknown fields are rejected. +UNRECOGNIZED_FIELD_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "unknown_single_field", + command={"commitTransaction": 1, "unknownField": 1}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="commitTransaction should reject single unknown field", + ), + CommandTestCase( + "unknown_multiple_fields", + command={"commitTransaction": 1, "foo": 1, "bar": 2}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="commitTransaction should reject multiple unknown fields", + ), +] + +# Property [Case Sensitivity]: field names are case-sensitive and wrong-case variants are rejected. +CASE_SENSITIVITY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "case_WriteConcern", + command={"commitTransaction": 1, "WriteConcern": {"w": 1}}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="commitTransaction should reject 'WriteConcern' (capital W) as unrecognized", + ), + CommandTestCase( + "case_Autocommit", + command={"commitTransaction": 1, "Autocommit": False}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="commitTransaction should reject 'Autocommit' (capital A) as unrecognized", + ), + CommandTestCase( + "case_TxnNumber", + command={"commitTransaction": 1, "TxnNumber": Int64(1)}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="commitTransaction should reject 'TxnNumber' (capital T) as unrecognized", + ), + CommandTestCase( + "case_Comment", + command={"commitTransaction": 1, "Comment": "test"}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="commitTransaction should reject 'Comment' (capital C) as unrecognized", + ), +] + +# Property [Foreign Field Rejection]: fields from other commands are rejected. +FOREIGN_FIELD_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "foreign_query", + command={"commitTransaction": 1, "query": {"x": 1}}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="commitTransaction should reject 'query' field from other commands", + ), + CommandTestCase( + "dollar_prefixed", + command={"commitTransaction": 1, "$unknown": 1}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="commitTransaction should reject dollar-prefixed unknown field", + ), +] + +# Property [writeConcern Unknown Sub-Field]: unknown writeConcern sub-fields are rejected. +WRITECONCERN_UNKNOWN_SUBFIELD_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "wc_unknown_subfield", + command={"commitTransaction": 1, "writeConcern": {"w": 1, "unknownOption": True}}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="commitTransaction should reject unknown writeConcern sub-field", + ), +] + +UNRECOGNIZED_TESTS: list[CommandTestCase] = ( + UNRECOGNIZED_FIELD_TESTS + + CASE_SENSITIVITY_TESTS + + FOREIGN_FIELD_TESTS + + WRITECONCERN_UNKNOWN_SUBFIELD_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(UNRECOGNIZED_TESTS)) +def test_commitTransaction_unrecognized_fields_error(collection, test): + """Test commitTransaction unrecognized field handling.""" + result = execute_admin_command(collection, test.command) + assertFailureCode(result, test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_visibility.py b/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_visibility.py new file mode 100644 index 000000000..445736b7d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_visibility.py @@ -0,0 +1,40 @@ +"""Tests for the visibility of a transaction's pending writes before commit. + +A write made inside an open transaction must not be visible to readers outside +the transaction until it commits, but must be visible to reads issued within +the same session. Each test observes the pending write before committing, then +commits to end the transaction. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.executor import execute_command + +pytestmark = [pytest.mark.admin, pytest.mark.requires(transactions=True)] + + +def test_pending_write_not_visible_outside_before_commit(collection): + """A pending insert is not visible to a reader outside the transaction.""" + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + collection.insert_one({"_id": 1, "x": 1}, session=session) + outside = execute_command(collection, {"find": collection.name, "filter": {}}) + session.commit_transaction() + assertSuccess(outside, []) + + +def test_pending_write_visible_in_session_before_commit(collection): + """A pending insert is visible to a read in the same session before commit.""" + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + collection.insert_one({"_id": 1, "x": 1}, session=session) + in_session = execute_command( + collection, {"find": collection.name, "filter": {}}, session=session + ) + session.commit_transaction() + assertSuccess(in_session, [{"_id": 1, "x": 1}]) diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_write_conflict.py b/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_write_conflict.py new file mode 100644 index 000000000..27208186c --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_write_conflict.py @@ -0,0 +1,34 @@ +"""Test write-conflict behavior for concurrent transactions. + +When two open transactions write to the same document, the second writer fails +immediately with WriteConflict rather than blocking (first-committer-wins). The +first transaction commits; the conflicting session is left for its ``with`` +block to close. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import WRITE_CONFLICT_ERROR +from documentdb_tests.framework.executor import execute_command + +pytestmark = [pytest.mark.admin, pytest.mark.requires(transactions=True)] + + +def test_second_writer_to_open_doc_fails_with_write_conflict(collection): + """A second writer to a doc already modified in an open txn fails with WriteConflict.""" + collection.insert_one({"_id": 1, "x": 0}) + client = collection.database.client + with client.start_session() as first, client.start_session() as second: + first.start_transaction() + collection.update_one({"_id": 1}, {"$set": {"x": 1}}, session=first) + second.start_transaction() + result = execute_command( + collection, + {"update": collection.name, "updates": [{"q": {"_id": 1}, "u": {"$set": {"x": 2}}}]}, + session=second, + ) + first.commit_transaction() + assertFailureCode(result, WRITE_CONFLICT_ERROR) diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_writeconcern.py b/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_writeconcern.py new file mode 100644 index 000000000..12a254216 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_writeconcern.py @@ -0,0 +1,192 @@ +"""Tests for commitTransaction writeConcern parameter acceptance in a real transaction. + +Validates that accepted writeConcern variants (document types, w sub-field +values, j sub-field values, wtimeout sub-field values, and edge cases) succeed +when commitTransaction is issued inside an active transaction on a replica set. +""" + +from __future__ import annotations + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertSuccessPartial +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = [pytest.mark.admin, pytest.mark.requires(transactions=True)] + +# Property [writeConcern Document Acceptance]: writeConcern accepts document values. +WRITECONCERN_ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "writeconcern_empty_doc", + command={"commitTransaction": 1, "writeConcern": {}}, + msg="commitTransaction should accept empty writeConcern document", + ), + CommandTestCase( + "writeconcern_null", + command={"commitTransaction": 1, "writeConcern": None}, + msg="commitTransaction should accept writeConcern:null", + ), + CommandTestCase( + "wc_combined_w_j_wtimeout", + command={ + "commitTransaction": 1, + "writeConcern": {"w": "majority", "j": True, "wtimeout": 10_000}, + }, + msg="commitTransaction should accept combined w + j + wtimeout", + ), + CommandTestCase( + "wc_w0_j_true", + command={"commitTransaction": 1, "writeConcern": {"w": 0, "j": True}}, + msg="commitTransaction should accept conflicting w:0 with j:true", + ), + CommandTestCase( + "wc_fsync_true", + command={"commitTransaction": 1, "writeConcern": {"fsync": True}}, + msg="commitTransaction should accept legacy writeConcern.fsync:true", + ), +] + +# Property [w Accepted Values]: w accepts int and string "majority" values. +W_ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "w_int32_one", + command={"commitTransaction": 1, "writeConcern": {"w": 1}}, + msg="commitTransaction should accept writeConcern.w:1", + ), + CommandTestCase( + "w_int32_zero", + command={"commitTransaction": 1, "writeConcern": {"w": 0}}, + msg="commitTransaction should accept writeConcern.w:0 (unacknowledged)", + ), + CommandTestCase( + "w_majority", + command={"commitTransaction": 1, "writeConcern": {"w": "majority"}}, + msg="commitTransaction should accept writeConcern.w:'majority'", + ), + CommandTestCase( + "w_int64", + command={"commitTransaction": 1, "writeConcern": {"w": Int64(1)}}, + msg="commitTransaction should accept writeConcern.w:Int64(1)", + ), + CommandTestCase( + "w_double_whole", + command={"commitTransaction": 1, "writeConcern": {"w": 1.0}}, + msg="commitTransaction should accept writeConcern.w:1.0", + ), + CommandTestCase( + "w_double_fractional", + command={"commitTransaction": 1, "writeConcern": {"w": 1.5}}, + msg="commitTransaction should accept writeConcern.w:1.5", + ), + CommandTestCase( + "w_decimal128", + command={"commitTransaction": 1, "writeConcern": {"w": Decimal128("1")}}, + msg="commitTransaction should accept writeConcern.w:Decimal128('1')", + ), +] + +# Property [j Accepted Values]: j accepts boolean and numeric types. +J_ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "j_bool_true", + command={"commitTransaction": 1, "writeConcern": {"j": True}}, + msg="commitTransaction should accept writeConcern.j:true", + ), + CommandTestCase( + "j_bool_false", + command={"commitTransaction": 1, "writeConcern": {"j": False}}, + msg="commitTransaction should accept writeConcern.j:false", + ), + CommandTestCase( + "j_int32_one", + command={"commitTransaction": 1, "writeConcern": {"j": 1}}, + msg="commitTransaction should accept writeConcern.j:1 (coerced to true)", + ), + CommandTestCase( + "j_int32_zero", + command={"commitTransaction": 1, "writeConcern": {"j": 0}}, + msg="commitTransaction should accept writeConcern.j:0 (coerced to false)", + ), + CommandTestCase( + "j_null", + command={"commitTransaction": 1, "writeConcern": {"j": None}}, + msg="commitTransaction should accept writeConcern.j:null", + ), +] + +# Property [wtimeout Accepted Values]: wtimeout accepts numeric types broadly. +WTIMEOUT_ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "wtimeout_int32_positive", + command={"commitTransaction": 1, "writeConcern": {"wtimeout": 1000}}, + msg="commitTransaction should accept writeConcern.wtimeout:1000", + ), + CommandTestCase( + "wtimeout_int32_zero", + command={"commitTransaction": 1, "writeConcern": {"wtimeout": 0}}, + msg="commitTransaction should accept writeConcern.wtimeout:0 (no timeout)", + ), + CommandTestCase( + "wtimeout_int64", + command={"commitTransaction": 1, "writeConcern": {"wtimeout": Int64(1000)}}, + msg="commitTransaction should accept writeConcern.wtimeout:Int64(1000)", + ), + CommandTestCase( + "wtimeout_double_whole", + command={"commitTransaction": 1, "writeConcern": {"wtimeout": 1000.0}}, + msg="commitTransaction should accept writeConcern.wtimeout:1000.0", + ), + CommandTestCase( + "wtimeout_negative", + command={"commitTransaction": 1, "writeConcern": {"wtimeout": -1}}, + msg="commitTransaction should accept writeConcern.wtimeout:-1", + ), + CommandTestCase( + "wtimeout_string", + command={"commitTransaction": 1, "writeConcern": {"wtimeout": "1000"}}, + msg="commitTransaction should accept writeConcern.wtimeout:'1000'", + ), + CommandTestCase( + "wtimeout_bool", + command={"commitTransaction": 1, "writeConcern": {"wtimeout": True}}, + msg="commitTransaction should accept writeConcern.wtimeout:true", + ), + CommandTestCase( + "wtimeout_null", + command={"commitTransaction": 1, "writeConcern": {"wtimeout": None}}, + msg="commitTransaction should accept writeConcern.wtimeout:null", + ), + CommandTestCase( + "wtimeout_object", + command={"commitTransaction": 1, "writeConcern": {"wtimeout": {}}}, + msg="commitTransaction should accept writeConcern.wtimeout:{}", + ), + CommandTestCase( + "wtimeout_array", + command={"commitTransaction": 1, "writeConcern": {"wtimeout": []}}, + msg="commitTransaction should accept writeConcern.wtimeout:[]", + ), +] + +WRITECONCERN_TESTS: list[CommandTestCase] = ( + WRITECONCERN_ACCEPTANCE_TESTS + + W_ACCEPTANCE_TESTS + + J_ACCEPTANCE_TESTS + + WTIMEOUT_ACCEPTANCE_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(WRITECONCERN_TESTS)) +def test_commitTransaction_writeconcern(collection, test): + """Test commitTransaction writeConcern parameter acceptance in a transaction.""" + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + collection.insert_one({"_id": 1}, session=session) + result = execute_admin_command(collection, test.command, session=session) + assertSuccessPartial(result, {"ok": 1.0}, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_writeconcern_error.py b/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_writeconcern_error.py new file mode 100644 index 000000000..832623c01 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_writeconcern_error.py @@ -0,0 +1,256 @@ +"""Tests for commitTransaction writeConcern parameter error cases. + +Validates that invalid writeConcern types and sub-field values are rejected +with the appropriate error codes. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import ( + COMMAND_FAILED_ERROR, + FAILED_TO_PARSE_ERROR, + TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = [pytest.mark.admin, pytest.mark.requires(transactions=True)] + + +# --------------------------------------------------------------------------- +# Property [writeConcern Type Rejection]: non-document types are rejected with TypeMismatch. +# --------------------------------------------------------------------------- + +WRITECONCERN_TYPE_REJECTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "writeconcern_string", + command={"commitTransaction": 1, "writeConcern": "majority"}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject writeConcern:string as wrong type", + ), + CommandTestCase( + "writeconcern_int32", + command={"commitTransaction": 1, "writeConcern": 1}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject writeConcern:int32 as wrong type", + ), + CommandTestCase( + "writeconcern_int64", + command={"commitTransaction": 1, "writeConcern": Int64(1)}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject writeConcern:Int64 as wrong type", + ), + CommandTestCase( + "writeconcern_double", + command={"commitTransaction": 1, "writeConcern": 1.0}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject writeConcern:double as wrong type", + ), + CommandTestCase( + "writeconcern_decimal128", + command={"commitTransaction": 1, "writeConcern": Decimal128("1")}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject writeConcern:Decimal128 as wrong type", + ), + CommandTestCase( + "writeconcern_bool_true", + command={"commitTransaction": 1, "writeConcern": True}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject writeConcern:true as wrong type", + ), + CommandTestCase( + "writeconcern_bool_false", + command={"commitTransaction": 1, "writeConcern": False}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject writeConcern:false as wrong type", + ), + CommandTestCase( + "writeconcern_array_empty", + command={"commitTransaction": 1, "writeConcern": []}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject writeConcern:[] as wrong type", + ), + CommandTestCase( + "writeconcern_array_nonempty", + command={"commitTransaction": 1, "writeConcern": [{"w": 1}]}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject writeConcern:[{w:1}] as wrong type", + ), + CommandTestCase( + "writeconcern_binary", + command={"commitTransaction": 1, "writeConcern": Binary(b"\x00")}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject writeConcern:Binary as wrong type", + ), + CommandTestCase( + "writeconcern_objectid", + command={"commitTransaction": 1, "writeConcern": ObjectId()}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject writeConcern:ObjectId as wrong type", + ), + CommandTestCase( + "writeconcern_datetime", + command={"commitTransaction": 1, "writeConcern": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject writeConcern:datetime as wrong type", + ), + CommandTestCase( + "writeconcern_regex", + command={"commitTransaction": 1, "writeConcern": Regex(".*")}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject writeConcern:Regex as wrong type", + ), + CommandTestCase( + "writeconcern_timestamp", + command={"commitTransaction": 1, "writeConcern": Timestamp(0, 0)}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject writeConcern:Timestamp as wrong type", + ), + CommandTestCase( + "writeconcern_code", + command={"commitTransaction": 1, "writeConcern": Code("function(){}")}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject writeConcern:Code as wrong type", + ), + CommandTestCase( + "writeconcern_minkey", + command={"commitTransaction": 1, "writeConcern": MinKey()}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject writeConcern:MinKey as wrong type", + ), + CommandTestCase( + "writeconcern_maxkey", + command={"commitTransaction": 1, "writeConcern": MaxKey()}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject writeConcern:MaxKey as wrong type", + ), +] + + +# --------------------------------------------------------------------------- +# Property [w Invalid Values]: invalid w values are rejected with CommandFailed or FailedToParse. +# --------------------------------------------------------------------------- + +W_INVALID_VALUE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "w_custom_tag", + command={"commitTransaction": 1, "writeConcern": {"w": "myTag"}}, + error_code=COMMAND_FAILED_ERROR, + msg="commitTransaction should reject writeConcern.w:'myTag' with CommandFailed", + ), + CommandTestCase( + "w_empty_string", + command={"commitTransaction": 1, "writeConcern": {"w": ""}}, + error_code=COMMAND_FAILED_ERROR, + msg="commitTransaction should reject writeConcern.w:'' with CommandFailed", + ), + CommandTestCase( + "w_null", + command={"commitTransaction": 1, "writeConcern": {"w": None}}, + error_code=COMMAND_FAILED_ERROR, + msg="commitTransaction should reject writeConcern.w:null with CommandFailed", + ), + CommandTestCase( + "w_negative_int", + command={"commitTransaction": 1, "writeConcern": {"w": -1}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="commitTransaction should reject writeConcern.w:-1 with FailedToParse", + ), + CommandTestCase( + "w_int32_max", + command={"commitTransaction": 1, "writeConcern": {"w": 2_147_483_647}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="commitTransaction should reject writeConcern.w:INT32_MAX with FailedToParse", + ), + CommandTestCase( + "w_bool_false", + command={"commitTransaction": 1, "writeConcern": {"w": False}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="commitTransaction should reject writeConcern.w:false with FailedToParse", + ), + CommandTestCase( + "w_bool_true", + command={"commitTransaction": 1, "writeConcern": {"w": True}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="commitTransaction should reject writeConcern.w:true with FailedToParse", + ), + CommandTestCase( + "w_object", + command={"commitTransaction": 1, "writeConcern": {"w": {}}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="commitTransaction should reject writeConcern.w:{} with FailedToParse", + ), + CommandTestCase( + "w_array", + command={"commitTransaction": 1, "writeConcern": {"w": []}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="commitTransaction should reject writeConcern.w:[] with FailedToParse", + ), +] + + +# --------------------------------------------------------------------------- +# Property [j Type Rejection]: non-boolean non-numeric types are rejected with TypeMismatch. +# --------------------------------------------------------------------------- + +J_TYPE_REJECTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "j_string", + command={"commitTransaction": 1, "writeConcern": {"j": "true"}}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject writeConcern.j:'true' as wrong type", + ), + CommandTestCase( + "j_object", + command={"commitTransaction": 1, "writeConcern": {"j": {}}}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject writeConcern.j:{} as wrong type", + ), + CommandTestCase( + "j_array", + command={"commitTransaction": 1, "writeConcern": {"j": []}}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject writeConcern.j:[] as wrong type", + ), +] + + +# --------------------------------------------------------------------------- +# Property [wtimeout Overflow]: Int64 max value overflows and produces FailedToParse. +# --------------------------------------------------------------------------- + +WTIMEOUT_OVERFLOW_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "wtimeout_int64_max", + command={ + "commitTransaction": 1, + "writeConcern": {"wtimeout": Int64(9_223_372_036_854_775_807)}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="commitTransaction should reject writeConcern.wtimeout:Int64 max with FailedToParse", + ), +] + + +WRITECONCERN_ERROR_TESTS: list[CommandTestCase] = ( + WRITECONCERN_TYPE_REJECTION_TESTS + + W_INVALID_VALUE_TESTS + + J_TYPE_REJECTION_TESTS + + WTIMEOUT_OVERFLOW_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(WRITECONCERN_ERROR_TESTS)) +def test_commitTransaction_writeconcern_error(collection, test): + """Test commitTransaction writeConcern parameter error cases.""" + result = execute_admin_command(collection, test.command) + assertFailureCode(result, test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_smoke_commitTransaction.py b/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_smoke_commitTransaction.py index 3fc6da5e3..9d95e6566 100644 --- a/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_smoke_commitTransaction.py +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_smoke_commitTransaction.py @@ -9,7 +9,7 @@ from documentdb_tests.framework.assertions import assertFailure from documentdb_tests.framework.executor import execute_admin_command -pytestmark = pytest.mark.smoke +pytestmark = [pytest.mark.smoke, pytest.mark.requires(transactions=True)] def test_smoke_commitTransaction(collection): diff --git a/documentdb_tests/framework/error_codes.py b/documentdb_tests/framework/error_codes.py index fff507521..1456e8ed9 100644 --- a/documentdb_tests/framework/error_codes.py +++ b/documentdb_tests/framework/error_codes.py @@ -37,6 +37,7 @@ INDEX_OPTIONS_CONFLICT_ERROR = 85 INDEX_KEY_SPECS_CONFLICT_ERROR = 86 OPERATION_FAILED_ERROR = 96 +WRITE_CONFLICT_ERROR = 112 COMMAND_NOT_SUPPORTED_ERROR = 115 DOCUMENT_VALIDATION_FAILURE_ERROR = 121 NOT_A_REPLICA_SET_ERROR = 123 From 5e6e4f58e4a41f9bd5af1915a3ecbe63aac0f4cb Mon Sep 17 00:00:00 2001 From: "Paterson.How" <71473631+PatersonProjects@users.noreply.github.com> Date: Mon, 6 Jul 2026 18:20:44 -0700 Subject: [PATCH 06/35] Add getClusterParameter tests (#656) Signed-off-by: PatersonProjects Signed-off-by: RyanGarfinkel <113050972+RyanGarfinkel@users.noreply.github.com> --- .../commands/getClusterParameter/__init__.py | 1 + ...t_getClusterParameter_argument_handling.py | 92 ++++++++++++ .../test_getClusterParameter_core_behavior.py | 41 ++++++ .../test_getClusterParameter_errors.py | 136 ++++++++++++++++++ ..._getClusterParameter_response_structure.py | 60 ++++++++ .../utils/administration_test_case.py | 19 +++ documentdb_tests/framework/property_checks.py | 19 +++ 7 files changed, 368 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/getClusterParameter/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/getClusterParameter/test_getClusterParameter_argument_handling.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/getClusterParameter/test_getClusterParameter_core_behavior.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/getClusterParameter/test_getClusterParameter_errors.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/getClusterParameter/test_getClusterParameter_response_structure.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/utils/administration_test_case.py diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/getClusterParameter/__init__.py b/documentdb_tests/compatibility/tests/system/administration/commands/getClusterParameter/__init__.py new file mode 100644 index 000000000..e07018905 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/getClusterParameter/__init__.py @@ -0,0 +1 @@ +"""getClusterParameter command tests.""" diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/getClusterParameter/test_getClusterParameter_argument_handling.py b/documentdb_tests/compatibility/tests/system/administration/commands/getClusterParameter/test_getClusterParameter_argument_handling.py new file mode 100644 index 000000000..3251bfafb --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/getClusterParameter/test_getClusterParameter_argument_handling.py @@ -0,0 +1,92 @@ +"""Tests for getClusterParameter argument handling. + +Covers accepted argument forms (single string, wildcard, array of +strings, duplicate names, unrecognized extra field) and BSON type +rejection for the command argument. +""" + +import pytest + +from documentdb_tests.compatibility.tests.system.administration.utils.administration_test_case import ( # noqa: E501 + AdministrationTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode, assertProperties +from documentdb_tests.framework.bson_type_validator import ( + BsonTypeTestCase, + generate_bson_rejection_test_cases, +) +from documentdb_tests.framework.error_codes import TYPE_MISMATCH_ERROR +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, Len +from documentdb_tests.framework.test_constants import BsonType + +pytestmark = pytest.mark.admin + +_VALID_PARAM = "changeStreamOptions" +_VALID_PARAM_2 = "defaultMaxTimeMS" + +_ARGUMENT_TYPE_SPEC = [ + BsonTypeTestCase( + id="getClusterParameter_argument", + msg="getClusterParameter should reject non-string/non-array argument types", + keyword="getClusterParameter", + valid_types=[BsonType.STRING, BsonType.ARRAY], + default_error_code=TYPE_MISMATCH_ERROR, + skip_rejection_types=[BsonType.NULL], + ), +] + +_ARGUMENT_REJECTION_CASES = generate_bson_rejection_test_cases(_ARGUMENT_TYPE_SPEC) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", _ARGUMENT_REJECTION_CASES) +def test_getClusterParameter_argument_rejects_type(collection, bson_type, sample_value, spec): + """Test getClusterParameter rejects non-string/non-array argument types.""" + result = execute_admin_command(collection, {"getClusterParameter": sample_value}) + assertFailureCode( + result, + spec.expected_code(bson_type), + msg=f"getClusterParameter should reject {bson_type.value} argument.", + ) + + +ARGUMENT_FORM_TESTS: list[AdministrationTestCase] = [ + AdministrationTestCase( + id="wildcard_returns_all", + command={"getClusterParameter": "*"}, + checks={"ok": Eq(1.0)}, + msg="Wildcard '*' should return ok:1", + ), + AdministrationTestCase( + id="single_name_returns_one", + command={"getClusterParameter": _VALID_PARAM}, + checks={"ok": Eq(1.0), "clusterParameters": Len(1)}, + msg="Single name should return ok:1 with one parameter", + ), + AdministrationTestCase( + id="array_two_names_returns_two", + command={"getClusterParameter": [_VALID_PARAM, _VALID_PARAM_2]}, + checks={"ok": Eq(1.0), "clusterParameters": Len(2)}, + msg="Array of two names should return two parameters", + ), + AdministrationTestCase( + id="array_duplicate_names", + command={"getClusterParameter": [_VALID_PARAM, _VALID_PARAM]}, + checks={"ok": Eq(1.0)}, + msg="Duplicate names in array should succeed", + ), + AdministrationTestCase( + id="unrecognized_field_accepted", + command={"getClusterParameter": "*", "unknownField": "test"}, + checks={"ok": Eq(1.0)}, + msg="Unrecognized extra field should be accepted", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(ARGUMENT_FORM_TESTS)) +def test_getClusterParameter_argument_forms(collection, test): + """Test accepted argument forms each return ok:1 with expected clusterParameters length.""" + result = execute_admin_command(collection, test.command) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/getClusterParameter/test_getClusterParameter_core_behavior.py b/documentdb_tests/compatibility/tests/system/administration/commands/getClusterParameter/test_getClusterParameter_core_behavior.py new file mode 100644 index 000000000..10b317b59 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/getClusterParameter/test_getClusterParameter_core_behavior.py @@ -0,0 +1,41 @@ +"""Tests for getClusterParameter core retrieval behavior. + +Verifies that the wildcard returns more than one cluster parameter and +that the result includes known parameters by name. +""" + +import pytest + +from documentdb_tests.compatibility.tests.system.administration.utils.administration_test_case import ( # noqa: E501 + AdministrationTestCase, +) +from documentdb_tests.framework.assertions import assertProperties +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Contains, LenGt + +pytestmark = pytest.mark.admin + +_VALID_PARAM = "changeStreamOptions" + +CORE_BEHAVIOR_TESTS: list[AdministrationTestCase] = [ + AdministrationTestCase( + id="wildcard_returns_multiple_params", + command={"getClusterParameter": "*"}, + checks={"clusterParameters": LenGt(1)}, + msg="Wildcard should return more than one cluster parameter", + ), + AdministrationTestCase( + id="wildcard_includes_known_param", + command={"getClusterParameter": "*"}, + checks={"clusterParameters": Contains("_id", _VALID_PARAM)}, + msg=f"Wildcard result should include '{_VALID_PARAM}'", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(CORE_BEHAVIOR_TESTS)) +def test_getClusterParameter_core_behavior(collection, test): + """Test core retrieval behavior: wildcard parameter count and inclusion.""" + result = execute_admin_command(collection, test.command) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/getClusterParameter/test_getClusterParameter_errors.py b/documentdb_tests/compatibility/tests/system/administration/commands/getClusterParameter/test_getClusterParameter_errors.py new file mode 100644 index 000000000..3d5992c4f --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/getClusterParameter/test_getClusterParameter_errors.py @@ -0,0 +1,136 @@ +"""Tests for getClusterParameter error cases. + +Covers error-producing inputs: unknown parameter names, empty and +null arguments, array element type errors, command-key case +sensitivity, and key ordering enforcement. Also verifies that the +command is rejected on non-admin databases. +""" + +import pytest + +from documentdb_tests.compatibility.tests.system.administration.utils.administration_test_case import ( # noqa: E501 + AdministrationTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + COMMAND_NOT_FOUND_ERROR, + NO_SUCH_KEY_ERROR, + TYPE_MISMATCH_ERROR, + UNAUTHORIZED_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = pytest.mark.admin + +_VALID_PARAM = "changeStreamOptions" + + +_NO_SUCH_KEY_CASES: list[AdministrationTestCase] = [ + AdministrationTestCase( + id="unknown_single_string_errors", + command={"getClusterParameter": "unknownParam"}, + error_code=NO_SUCH_KEY_ERROR, + msg="Single unknown name should fail with no-such-parameter error", + ), + AdministrationTestCase( + id="empty_string_argument", + command={"getClusterParameter": ""}, + error_code=NO_SUCH_KEY_ERROR, + msg="Empty-string argument should be treated as an unknown parameter name", + ), + AdministrationTestCase( + id="case_altered", + command={"getClusterParameter": "ChangeStreamOptions"}, + error_code=NO_SUCH_KEY_ERROR, + msg="Altered-case known name should not match (case-sensitive)", + ), + AdministrationTestCase( + id="star_in_array_is_literal_name", + command={"getClusterParameter": ["*"]}, + error_code=NO_SUCH_KEY_ERROR, + msg="'*' inside an array is a literal name, not a wildcard", + ), + AdministrationTestCase( + id="array_mixed_valid_unknown_errors", + command={"getClusterParameter": [_VALID_PARAM, "unknownParam"]}, + error_code=NO_SUCH_KEY_ERROR, + msg="Unknown entry in mixed array should fail", + ), +] + +_TYPE_ERROR_CASES: list[AdministrationTestCase] = [ + AdministrationTestCase( + id="empty_array_errors", + command={"getClusterParameter": []}, + error_code=BAD_VALUE_ERROR, + msg="Empty array must supply at least one name", + ), + AdministrationTestCase( + id="array_nonstring_element_rejects", + command={"getClusterParameter": [_VALID_PARAM, 123]}, + error_code=TYPE_MISMATCH_ERROR, + msg="Non-string array element should be rejected", + ), + AdministrationTestCase( + id="null_argument", + command={"getClusterParameter": None}, + error_code=TYPE_MISMATCH_ERROR, + msg="Null argument should be rejected as a type mismatch", + ), + AdministrationTestCase( + id="array_null_element_rejects", + command={"getClusterParameter": [None]}, + error_code=TYPE_MISMATCH_ERROR, + msg="Null element in array should be rejected", + ), + AdministrationTestCase( + id="array_doc_element_rejects", + command={"getClusterParameter": [{"a": 1}]}, + error_code=TYPE_MISMATCH_ERROR, + msg="Document element in array should be rejected", + ), + AdministrationTestCase( + id="array_nested_array_rejects", + command={"getClusterParameter": [[_VALID_PARAM]]}, + error_code=TYPE_MISMATCH_ERROR, + msg="Nested array element should be rejected", + ), +] + +_COMMAND_ROUTING_CASES: list[AdministrationTestCase] = [ + AdministrationTestCase( + id="wrong_case_command_key_rejected", + command={"getclusterparameter": "*"}, + error_code=COMMAND_NOT_FOUND_ERROR, + msg="Wrong-case command key should be rejected", + ), + AdministrationTestCase( + id="command_key_not_first_fails", + command={"comment": "test", "getClusterParameter": "*"}, + error_code=COMMAND_NOT_FOUND_ERROR, + msg="getClusterParameter must be the first key in the command document", + ), +] + +_ERROR_CASES: list[AdministrationTestCase] = ( + _NO_SUCH_KEY_CASES + _TYPE_ERROR_CASES + _COMMAND_ROUTING_CASES +) + + +@pytest.mark.parametrize("test", pytest_params(_ERROR_CASES)) +def test_getClusterParameter_errors(collection, test): + """Test all error-producing inputs: unknown names, type mismatches, and command routing.""" + result = execute_admin_command(collection, test.command) + assertFailureCode(result, test.error_code, msg=test.msg) + + +def test_getClusterParameter_rejected_on_non_admin_database(collection): + """Test getClusterParameter is rejected against a non-admin database.""" + result = execute_command(collection, {"getClusterParameter": "*"}) + assertFailureCode( + result, + UNAUTHORIZED_ERROR, + msg="getClusterParameter should be rejected on a non-admin database.", + ) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/getClusterParameter/test_getClusterParameter_response_structure.py b/documentdb_tests/compatibility/tests/system/administration/commands/getClusterParameter/test_getClusterParameter_response_structure.py new file mode 100644 index 000000000..1530b9a06 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/getClusterParameter/test_getClusterParameter_response_structure.py @@ -0,0 +1,60 @@ +"""Tests for getClusterParameter response structure. + +Verifies the top-level shape of the success response: ok is 1, +clusterParameters is an array, and a single-name request returns +exactly one element. +""" + +import pytest + +from documentdb_tests.compatibility.tests.system.administration.utils.administration_test_case import ( # noqa: E501 + AdministrationTestCase, +) +from documentdb_tests.framework.assertions import assertProperties +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Contains, Eq, IsType, Len + +pytestmark = pytest.mark.admin + +_VALID_PARAM = "changeStreamOptions" + +PROPERTY_TESTS: list[AdministrationTestCase] = [ + AdministrationTestCase( + id="ok_is_1", + command={"getClusterParameter": "*"}, + checks={"ok": Eq(1.0)}, + msg="Response ok should be 1.0", + ), + AdministrationTestCase( + id="clusterParameters_is_array", + command={"getClusterParameter": "*"}, + checks={"clusterParameters": IsType("array")}, + msg="clusterParameters should be an array", + ), + AdministrationTestCase( + id="single_name_length_is_one", + command={"getClusterParameter": _VALID_PARAM}, + checks={"clusterParameters": Len(1)}, + msg="Single-name request should return exactly one element", + ), + AdministrationTestCase( + id="element_id_matches_request", + command={"getClusterParameter": _VALID_PARAM}, + checks={"clusterParameters.0._id": Eq(_VALID_PARAM)}, + msg=f"Single-name request should return element with _id equal to '{_VALID_PARAM}'", + ), + AdministrationTestCase( + id="wildcard_includes_fleDisableSubstringPreviewParameterLimits", + command={"getClusterParameter": "*"}, + checks={"clusterParameters": Contains("_id", "fleDisableSubstringPreviewParameterLimits")}, + msg="Wildcard result should include 'fleDisableSubstringPreviewParameterLimits'", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(PROPERTY_TESTS)) +def test_getClusterParameter_response_properties(collection, test): + """Verifies getClusterParameter response fields have expected types and values.""" + result = execute_admin_command(collection, test.command) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) diff --git a/documentdb_tests/compatibility/tests/system/administration/utils/administration_test_case.py b/documentdb_tests/compatibility/tests/system/administration/utils/administration_test_case.py new file mode 100644 index 000000000..b9fbbd13c --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/utils/administration_test_case.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Dict, Optional + +from documentdb_tests.framework.test_case import BaseTestCase + + +@dataclass(frozen=True) +class AdministrationTestCase(BaseTestCase): + """Test case for administration command tests. + + Attributes: + command: The command dict to execute. + checks: Mapping of dotted field paths to property check objects. + """ + + command: Optional[Dict[str, Any]] = None + checks: Dict[str, Any] = field(default_factory=dict) diff --git a/documentdb_tests/framework/property_checks.py b/documentdb_tests/framework/property_checks.py index ef80dfb80..e61b5c32e 100644 --- a/documentdb_tests/framework/property_checks.py +++ b/documentdb_tests/framework/property_checks.py @@ -165,6 +165,25 @@ def __repr__(self) -> str: return f"{type(self).__name__}({self.expected!r})" +class LenGt(Check): + """Assert that the field is a list with length strictly greater than a minimum.""" + + def __init__(self, minimum: int) -> None: + self.minimum = minimum + + def check(self, value: Any, path: str) -> str | None: + if value is _FIELD_ABSENT: + return f"expected '{path}' length > {self.minimum}, but field is missing" + if not isinstance(value, list): + return f"expected '{path}' to be a list, got {type(value).__name__}" + if len(value) <= self.minimum: + return f"expected '{path}' length > {self.minimum}, got {len(value)}" + return None + + def __repr__(self) -> str: + return f"{type(self).__name__}({self.minimum!r})" + + class LenLte(Check): """Assert that the field is a list whose length is at most ``maximum``.""" From 88c52d3e86666b1eeaf62e77c536b9786f48c357 Mon Sep 17 00:00:00 2001 From: "Paterson.How" <71473631+PatersonProjects@users.noreply.github.com> Date: Mon, 6 Jul 2026 18:35:02 -0700 Subject: [PATCH 07/35] Add $ping tests (#616) Signed-off-by: PatersonProjects Signed-off-by: RyanGarfinkel <113050972+RyanGarfinkel@users.noreply.github.com> --- .../buildInfo/test_buildInfo_consistency.py | 8 --- .../diagnostic/commands/ping/__init__.py | 0 .../ping/test_ping_argument_handling.py | 71 +++++++++++++++++++ .../commands/ping/test_ping_core_behavior.py | 64 +++++++++++++++++ .../ping/test_ping_error_conditions.py | 54 ++++++++++++++ .../ping/test_ping_response_structure.py | 37 ++++++++++ 6 files changed, 226 insertions(+), 8 deletions(-) create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/ping/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/ping/test_ping_argument_handling.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/ping/test_ping_core_behavior.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/ping/test_ping_error_conditions.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/ping/test_ping_response_structure.py diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/buildInfo/test_buildInfo_consistency.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/buildInfo/test_buildInfo_consistency.py index 8b84638d2..084109813 100644 --- a/documentdb_tests/compatibility/tests/system/diagnostic/commands/buildInfo/test_buildInfo_consistency.py +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/buildInfo/test_buildInfo_consistency.py @@ -35,11 +35,3 @@ def test_buildInfo_same_result_any_database(collection): msg="Should return same result from any database", raw_res=True, ) - - -def test_buildInfo_nonexistent_database(collection): - """Test buildInfo succeeds when run on a non-existent database.""" - other_db = f"{collection.name}_nonexistent_db" - other_col = collection.database.client[other_db][collection.name] - result = execute_command(other_col, {"buildInfo": 1}) - assertSuccessPartial(result, {"ok": 1.0}, msg="Should succeed on non-existent database") diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/ping/__init__.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/ping/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/ping/test_ping_argument_handling.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/ping/test_ping_argument_handling.py new file mode 100644 index 000000000..f44608554 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/ping/test_ping_argument_handling.py @@ -0,0 +1,71 @@ +"""Tests for ping command argument handling. + +Validates that ping accepts any BSON type as its command value, as well as +numeric edge cases (negative int, zero, infinity). The command value does +not affect behavior — all inputs should return ok: 1. +""" + +import pytest + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, +) +from documentdb_tests.framework.assertions import assertProperties +from documentdb_tests.framework.bson_type_validator import ( + BsonType, + BsonTypeTestCase, + generate_bson_acceptance_test_cases, +) +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq +from documentdb_tests.framework.test_constants import FLOAT_INFINITY + +pytestmark = pytest.mark.admin + + +PING_BSON_TYPE_SPECS = [ + BsonTypeTestCase( + id="ping_value", + msg="ping command should accept any BSON type as value", + valid_types=list(BsonType), + ), +] + +ACCEPTANCE_CASES = generate_bson_acceptance_test_cases(PING_BSON_TYPE_SPECS) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", ACCEPTANCE_CASES) +def test_ping_argument_types(collection, bson_type, sample_value, spec): + """Test that ping accepts various BSON types as command value.""" + result = execute_admin_command(collection, {"ping": sample_value}) + assertProperties(result, {"ok": Eq(1.0)}, msg=spec.msg, raw_res=True) + + +NUMERIC_EDGE_CASES: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + id="negative_int", + command={"ping": -1}, + checks={"ok": Eq(1.0)}, + msg="Should accept negative int", + ), + DiagnosticTestCase( + id="int_0", + command={"ping": 0}, + checks={"ok": Eq(1.0)}, + msg="Should accept int 0", + ), + DiagnosticTestCase( + id="infinity", + command={"ping": FLOAT_INFINITY}, + checks={"ok": Eq(1.0)}, + msg="Should accept infinity", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(NUMERIC_EDGE_CASES)) +def test_ping_argument_numeric_edge_cases(collection, test): + """Test that ping accepts edge-case numeric values as command value.""" + result = execute_admin_command(collection, test.command) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/ping/test_ping_core_behavior.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/ping/test_ping_core_behavior.py new file mode 100644 index 000000000..6ca0adb9d --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/ping/test_ping_core_behavior.py @@ -0,0 +1,64 @@ +"""Tests for ping command core behavior. + +Validates that ping succeeds on both admin and non-admin databases, +can be executed repeatedly in succession, and works after other commands +or write activity. +""" + +import pytest + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, +) +from documentdb_tests.framework.assertions import assertProperties +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq + +pytestmark = pytest.mark.admin + + +DATABASE_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + id="admin_database", + command={"ping": 1}, + use_admin=True, + checks={"ok": Eq(1.0)}, + msg="Should succeed on admin database", + ), + DiagnosticTestCase( + id="non_admin_database", + command={"ping": 1}, + use_admin=False, + checks={"ok": Eq(1.0)}, + msg="Should succeed on non-admin database", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(DATABASE_TESTS)) +def test_ping_on_database(collection, test): + """Test ping returns ok:1 on both admin and non-admin databases.""" + if test.use_admin: + result = execute_admin_command(collection, test.command) + else: + result = execute_command(collection, test.command) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) + + +def test_ping_multiple_times_in_succession(collection): + """Test ping can be executed multiple times in succession.""" + for _ in range(3): + result = execute_admin_command(collection, {"ping": 1}) + assertProperties( + result, {"ok": Eq(1.0)}, msg="Should succeed on repeated execution", raw_res=True + ) + + +def test_ping_after_write_activity(collection): + """Test ping returns ok:1 immediately after write activity.""" + collection.insert_many([{"_id": i, "data": "x" * 100} for i in range(100)]) + result = execute_admin_command(collection, {"ping": 1}) + assertProperties( + result, {"ok": Eq(1.0)}, msg="Should succeed after write activity", raw_res=True + ) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/ping/test_ping_error_conditions.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/ping/test_ping_error_conditions.py new file mode 100644 index 000000000..e46e12c5a --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/ping/test_ping_error_conditions.py @@ -0,0 +1,54 @@ +"""Tests for ping command error conditions. + +Validates that invalid usages of ping produce appropriate errors. +""" + +import pytest + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import ( + COMMAND_NOT_FOUND_ERROR, + UNKNOWN_PIPELINE_STAGE_ERROR, + UNRECOGNIZED_COMMAND_FIELD_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = pytest.mark.admin + + +ERROR_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + id="unrecognized_field", + command={"ping": 1, "unknownField": "test"}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="Should reject unrecognized fields", + ), + DiagnosticTestCase( + id="case_sensitive", + command={"Ping": 1}, + error_code=COMMAND_NOT_FOUND_ERROR, + msg="Case-mismatched command name should fail", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(ERROR_TESTS)) +def test_ping_error_conditions(collection, test): + """Verifies ping rejects invalid usages with appropriate error codes.""" + result = execute_admin_command(collection, test.command) + assertFailureCode(result, test.error_code, msg=test.msg) + + +def test_ping_as_pipeline_stage(collection): + """Test that $ping is not a valid aggregation pipeline stage.""" + result = execute_command( + collection, + {"aggregate": collection.name, "pipeline": [{"$ping": 1}], "cursor": {}}, + ) + assertFailureCode( + result, UNKNOWN_PIPELINE_STAGE_ERROR, msg="ping should not be a valid pipeline stage" + ) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/ping/test_ping_response_structure.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/ping/test_ping_response_structure.py new file mode 100644 index 000000000..4c8418369 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/ping/test_ping_response_structure.py @@ -0,0 +1,37 @@ +"""Tests for ping command response structure. + +Validates the response fields and their types. +""" + +import pytest + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, +) +from documentdb_tests.framework.assertions import assertProperties +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, IsType + +pytestmark = pytest.mark.admin + + +RESPONSE_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + id="ok_field_value", + checks={"ok": Eq(1.0)}, + msg="'ok' field should be 1.0", + ), + DiagnosticTestCase( + id="ok_field_type", + checks={"ok": IsType("double")}, + msg="'ok' field should be a double", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(RESPONSE_TESTS)) +def test_ping_response_properties(collection, test): + """Verifies ping response fields have expected types and values.""" + result = execute_admin_command(collection, {"ping": 1}) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) From 2dda1a9589f5963e0e6904236401cb4ce9246ef1 Mon Sep 17 00:00:00 2001 From: "Alina (Xi) Li" <96995091+alinaliBQ@users.noreply.github.com> Date: Mon, 6 Jul 2026 18:51:32 -0700 Subject: [PATCH 08/35] Add `setFeatureCompatibilityVersion` tests (#647) Signed-off-by: Alina (Xi) Li Signed-off-by: RyanGarfinkel <113050972+RyanGarfinkel@users.noreply.github.com> --- .../__init__.py | 0 ...tibilityVersion_confirm_semantics_error.py | 89 ++++++++ ...tibilityVersion_confirm_truthy_coercion.py | 102 +++++++++ ...lityVersion_confirm_type_coercion_error.py | 127 ++++++++++++ ...atureCompatibilityVersion_core_behavior.py | 139 +++++++++++++ ...t_setFeatureCompatibilityVersion_errors.py | 193 ++++++++++++++++++ ...patibilityVersion_feature_not_supported.py | 34 +++ ...tyVersion_version_type_validation_error.py | 81 ++++++++ ...yVersion_version_value_validation_error.py | 117 +++++++++++ ...patibilityVersion_writeConcern_accepted.py | 132 ++++++++++++ ...tyVersion_writeConcern_validation_error.py | 113 ++++++++++ ...st_smoke_setFeatureCompatibilityVersion.py | 24 +-- .../utils/__init__.py | 0 .../setFeatureCompatibilityVersion_common.py | 16 ++ documentdb_tests/framework/error_codes.py | 3 + 15 files changed, 1153 insertions(+), 17 deletions(-) create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_confirm_semantics_error.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_confirm_truthy_coercion.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_confirm_type_coercion_error.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_core_behavior.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_errors.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_feature_not_supported.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_version_type_validation_error.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_version_value_validation_error.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_writeConcern_accepted.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_writeConcern_validation_error.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/utils/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/utils/setFeatureCompatibilityVersion_common.py diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/__init__.py b/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_confirm_semantics_error.py b/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_confirm_semantics_error.py new file mode 100644 index 000000000..530e6e5b6 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_confirm_semantics_error.py @@ -0,0 +1,89 @@ +"""Tests for setFeatureCompatibilityVersion confirm field semantics (error cases). + +Validates that omitting confirm or setting confirm:false prevents FCV changes. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import FCV_CONFIRM_REQUIRED_ERROR +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +from .utils.setFeatureCompatibilityVersion_common import get_fcv + +pytestmark = [pytest.mark.admin, pytest.mark.no_parallel] + + +# Property [Confirm Required]: version change without confirm (omitted or false) is rejected. +CONFIRM_REQUIRED_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "downgrade_without_confirm", + command=lambda ctx: {"setFeatureCompatibilityVersion": "OTHER_FCV"}, + error_code=FCV_CONFIRM_REQUIRED_ERROR, + msg="setFeatureCompatibilityVersion should reject downgrade without confirm field", + ), + CommandTestCase( + "confirm_false_rejects_change", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "OTHER_FCV", + "confirm": False, + }, + error_code=FCV_CONFIRM_REQUIRED_ERROR, + msg="setFeatureCompatibilityVersion should reject version change with confirm:false", + ), +] + +# Property [Upgrade Without Confirm]: upgrade without confirm is rejected. +UPGRADE_NO_CONFIRM_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "upgrade_without_confirm", + command=lambda ctx: {"setFeatureCompatibilityVersion": "ORIGINAL_FCV"}, + error_code=FCV_CONFIRM_REQUIRED_ERROR, + msg="setFeatureCompatibilityVersion should reject upgrade without confirm", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(CONFIRM_REQUIRED_TESTS)) +def test_setFeatureCompatibilityVersion_confirm_required(database_client, collection, test): + """Test setFeatureCompatibilityVersion rejects change without valid confirm.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + original = get_fcv(collection) + other = "8.0" if original != "8.0" else "8.2" + cmd = test.build_command(ctx) + cmd["setFeatureCompatibilityVersion"] = other + result = execute_admin_command(collection, cmd) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + + +@pytest.mark.parametrize("test", pytest_params(UPGRADE_NO_CONFIRM_TESTS)) +def test_setFeatureCompatibilityVersion_upgrade_without_confirm(database_client, collection, test): + """Test setFeatureCompatibilityVersion rejects upgrade when confirm is omitted.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + original = get_fcv(collection) + other = "8.0" if original != "8.0" else "8.2" + execute_admin_command(collection, {"setFeatureCompatibilityVersion": other, "confirm": True}) + cmd = test.build_command(ctx) + cmd["setFeatureCompatibilityVersion"] = original + result = execute_admin_command(collection, cmd) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + execute_admin_command(collection, {"setFeatureCompatibilityVersion": original, "confirm": True}) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_confirm_truthy_coercion.py b/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_confirm_truthy_coercion.py new file mode 100644 index 000000000..6a976a43a --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_confirm_truthy_coercion.py @@ -0,0 +1,102 @@ +"""Tests for setFeatureCompatibilityVersion confirm field truthy coercion. + +Validates that the confirm field accepts truthy numeric values +(int 1, double 1.0, Int64(1), Decimal128("1"), NaN, Infinity, -Infinity). +""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +from .utils.setFeatureCompatibilityVersion_common import get_fcv + +pytestmark = [pytest.mark.admin, pytest.mark.no_parallel] + + +# Property [Truthy Coercion]: confirm field accepts truthy numeric values. +CONFIRM_TRUTHY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "int_1", + command=lambda ctx: {"setFeatureCompatibilityVersion": "OTHER_FCV", "confirm": 1}, + expected={"ok": 1.0}, + msg="setFeatureCompatibilityVersion should accept confirm=1 (int) as true", + ), + CommandTestCase( + "double_1", + command=lambda ctx: {"setFeatureCompatibilityVersion": "OTHER_FCV", "confirm": 1.0}, + expected={"ok": 1.0}, + msg="setFeatureCompatibilityVersion should accept confirm=1.0 (double) as true", + ), + CommandTestCase( + "long_1", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "OTHER_FCV", + "confirm": Int64(1), + }, + expected={"ok": 1.0}, + msg="setFeatureCompatibilityVersion should accept confirm=Int64(1) as true", + ), + CommandTestCase( + "decimal_1", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "OTHER_FCV", + "confirm": Decimal128("1"), + }, + expected={"ok": 1.0}, + msg="setFeatureCompatibilityVersion should accept confirm=Decimal128('1') as true", + ), + CommandTestCase( + "nan", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "OTHER_FCV", + "confirm": float("nan"), + }, + expected={"ok": 1.0}, + msg="setFeatureCompatibilityVersion should accept confirm=NaN as true", + ), + CommandTestCase( + "infinity", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "OTHER_FCV", + "confirm": float("inf"), + }, + expected={"ok": 1.0}, + msg="setFeatureCompatibilityVersion should accept confirm=Infinity as true", + ), + CommandTestCase( + "negative_infinity", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "OTHER_FCV", + "confirm": float("-inf"), + }, + expected={"ok": 1.0}, + msg="setFeatureCompatibilityVersion should accept confirm=-Infinity as true", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(CONFIRM_TRUTHY_TESTS)) +def test_setFeatureCompatibilityVersion_confirm_truthy(database_client, collection, test): + """Test setFeatureCompatibilityVersion accepts truthy confirm values.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + current = get_fcv(collection) + other = "8.0" if current != "8.0" else "8.2" + cmd = test.build_command(ctx) + cmd["setFeatureCompatibilityVersion"] = other + result = execute_admin_command(collection, cmd) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + execute_admin_command(collection, {"setFeatureCompatibilityVersion": current, "confirm": True}) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_confirm_type_coercion_error.py b/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_confirm_type_coercion_error.py new file mode 100644 index 000000000..e1260e50e --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_confirm_type_coercion_error.py @@ -0,0 +1,127 @@ +"""Tests for setFeatureCompatibilityVersion confirm field type coercion (error cases). + +Validates that the confirm field treats falsy values as false and rejects +non-numeric, non-bool types. +""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + FCV_CONFIRM_REQUIRED_ERROR, + TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +from .utils.setFeatureCompatibilityVersion_common import get_fcv + +pytestmark = [pytest.mark.admin, pytest.mark.no_parallel] + + +# Property [Confirm Rejected]: confirm field treats falsy values as false +# and rejects non-numeric, non-bool types. +CONFIRM_REJECTED_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "int_0", + command=lambda ctx: {"setFeatureCompatibilityVersion": "OTHER_FCV", "confirm": 0}, + error_code=FCV_CONFIRM_REQUIRED_ERROR, + msg="setFeatureCompatibilityVersion should treat confirm=0 as false", + ), + CommandTestCase( + "double_0", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "OTHER_FCV", + "confirm": 0.0, + }, + error_code=FCV_CONFIRM_REQUIRED_ERROR, + msg="setFeatureCompatibilityVersion should treat confirm=0.0 as false", + ), + CommandTestCase( + "long_0", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "OTHER_FCV", + "confirm": Int64(0), + }, + error_code=FCV_CONFIRM_REQUIRED_ERROR, + msg="setFeatureCompatibilityVersion should treat confirm=Int64(0) as false", + ), + CommandTestCase( + "decimal_0", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "OTHER_FCV", + "confirm": Decimal128("0"), + }, + error_code=FCV_CONFIRM_REQUIRED_ERROR, + msg="setFeatureCompatibilityVersion should treat confirm=Decimal128('0') as false", + ), + CommandTestCase( + "null", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "OTHER_FCV", + "confirm": None, + }, + error_code=FCV_CONFIRM_REQUIRED_ERROR, + msg="setFeatureCompatibilityVersion should treat confirm=null as not-true", + ), + CommandTestCase( + "negative_zero", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "OTHER_FCV", + "confirm": -0.0, + }, + error_code=FCV_CONFIRM_REQUIRED_ERROR, + msg="setFeatureCompatibilityVersion should treat confirm=-0.0 as false", + ), + CommandTestCase( + "string_type", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "OTHER_FCV", + "confirm": "true", + }, + error_code=TYPE_MISMATCH_ERROR, + msg="setFeatureCompatibilityVersion should reject confirm as string type", + ), + CommandTestCase( + "object_type", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "OTHER_FCV", + "confirm": {"a": 1}, + }, + error_code=TYPE_MISMATCH_ERROR, + msg="setFeatureCompatibilityVersion should reject confirm as object type", + ), + CommandTestCase( + "array_type", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "OTHER_FCV", + "confirm": [True], + }, + error_code=TYPE_MISMATCH_ERROR, + msg="setFeatureCompatibilityVersion should reject confirm as array type", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(CONFIRM_REJECTED_TESTS)) +def test_setFeatureCompatibilityVersion_confirm_rejected(database_client, collection, test): + """Test setFeatureCompatibilityVersion rejects invalid confirm values.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + current = get_fcv(collection) + other = "8.0" if current != "8.0" else "8.2" + cmd = test.build_command(ctx) + cmd["setFeatureCompatibilityVersion"] = other + result = execute_admin_command(collection, cmd) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_core_behavior.py b/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_core_behavior.py new file mode 100644 index 000000000..8757587fa --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_core_behavior.py @@ -0,0 +1,139 @@ +"""Tests for setFeatureCompatibilityVersion core behavior (success cases). + +Validates idempotent set, downgrade/upgrade with confirm, and +getParameter readback after change. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult, assertSuccessPartial +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +from .utils.setFeatureCompatibilityVersion_common import get_fcv + +pytestmark = [pytest.mark.admin, pytest.mark.no_parallel] + + +# Property [Idempotent Set]: setting the current version succeeds and returns ok:1. +IDEMPOTENT_SET_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "idempotent_set_current_version", + command=lambda ctx: {"setFeatureCompatibilityVersion": "CURRENT_FCV", "confirm": True}, + expected={"ok": 1.0}, + msg="setFeatureCompatibilityVersion should succeed idempotently when re-setting" + " current version", + ), +] + + +# Property [Downgrade]: setFeatureCompatibilityVersion can downgrade with confirm:true. +DOWNGRADE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "downgrade_with_confirm", + command=lambda ctx: {"setFeatureCompatibilityVersion": "OTHER_FCV", "confirm": True}, + expected={"ok": 1.0}, + msg="setFeatureCompatibilityVersion should succeed when downgrading version with confirm", + ), +] + + +# Property [Upgrade]: setFeatureCompatibilityVersion can upgrade back. +UPGRADE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "upgrade_with_confirm", + command=lambda ctx: {"setFeatureCompatibilityVersion": "ORIGINAL_FCV", "confirm": True}, + expected={"ok": 1.0}, + msg="setFeatureCompatibilityVersion should succeed when upgrading version with confirm", + ), +] + + +# Property [GetParameter Reflects Change]: getParameter returns the new version after change. +GET_PARAMETER_AFTER_CHANGE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "getParameter_reflects_change", + command=lambda ctx: {"getParameter": 1, "featureCompatibilityVersion": 1}, + expected={"ok": 1.0}, + msg="getParameter should return the new version after setFeatureCompatibilityVersion", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(IDEMPOTENT_SET_TESTS)) +def test_setFeatureCompatibilityVersion_idempotent_set(database_client, collection, test): + """Test setFeatureCompatibilityVersion succeeds when re-setting the current version.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + fcv = get_fcv(collection) + execute_admin_command(collection, {"setFeatureCompatibilityVersion": fcv, "confirm": True}) + result = execute_admin_command( + collection, {"setFeatureCompatibilityVersion": fcv, "confirm": True} + ) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + + +@pytest.mark.parametrize("test", pytest_params(DOWNGRADE_TESTS)) +def test_setFeatureCompatibilityVersion_downgrade(database_client, collection, test): + """Test setFeatureCompatibilityVersion can downgrade with confirm:true.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + original = get_fcv(collection) + other = "8.0" if original != "8.0" else "8.2" + cmd = test.build_command(ctx) + cmd["setFeatureCompatibilityVersion"] = other + result = execute_admin_command(collection, cmd) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + execute_admin_command(collection, {"setFeatureCompatibilityVersion": original, "confirm": True}) + + +@pytest.mark.parametrize("test", pytest_params(UPGRADE_TESTS)) +def test_setFeatureCompatibilityVersion_upgrade(database_client, collection, test): + """Test setFeatureCompatibilityVersion can upgrade back to the original version.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + original = get_fcv(collection) + other = "8.0" if original != "8.0" else "8.2" + execute_admin_command(collection, {"setFeatureCompatibilityVersion": other, "confirm": True}) + cmd = test.build_command(ctx) + cmd["setFeatureCompatibilityVersion"] = original + result = execute_admin_command(collection, cmd) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + + +@pytest.mark.parametrize("test", pytest_params(GET_PARAMETER_AFTER_CHANGE_TESTS)) +def test_setFeatureCompatibilityVersion_getParameter_reflects_change( + database_client, collection, test +): + """Test getParameter returns the new version after setFeatureCompatibilityVersion.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + original = get_fcv(collection) + other = "8.0" if original != "8.0" else "8.2" + execute_admin_command(collection, {"setFeatureCompatibilityVersion": other, "confirm": True}) + result = execute_admin_command(collection, test.build_command(ctx)) + expected = {"ok": 1.0, "featureCompatibilityVersion": {"version": other}} + assertSuccessPartial(result, expected, msg=test.msg) + execute_admin_command(collection, {"setFeatureCompatibilityVersion": original, "confirm": True}) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_errors.py b/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_errors.py new file mode 100644 index 000000000..59ec92d10 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_errors.py @@ -0,0 +1,193 @@ +"""Tests for setFeatureCompatibilityVersion error cases. + +Covers database enforcement (user/local/config DB rejection), unknown/extra fields, +error response structure, and setParameter rejection. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + FCV_INVALID_VERSION_ERROR, + ILLEGAL_OPERATION_ERROR, + UNAUTHORIZED_ERROR, + UNRECOGNIZED_COMMAND_FIELD_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.parametrize import pytest_params + +from .utils.setFeatureCompatibilityVersion_common import get_fcv + +pytestmark = [pytest.mark.admin, pytest.mark.no_parallel] + + +# Property [User DB Rejected]: setFeatureCompatibilityVersion fails on a user database. +USER_DB_REJECTED_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "user_db_rejected", + error_code=UNAUTHORIZED_ERROR, + msg="setFeatureCompatibilityVersion should reject execution on a user database", + ), +] + + +# Property [System DB Rejected]: setFeatureCompatibilityVersion fails on local and config databases. +SYSTEM_DB_REJECTED_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "local_db_rejected", + error_code=UNAUTHORIZED_ERROR, + msg="setFeatureCompatibilityVersion should reject execution on the local database", + ), + CommandTestCase( + "config_db_rejected", + error_code=UNAUTHORIZED_ERROR, + msg="setFeatureCompatibilityVersion should reject execution on the config database", + ), +] + + +# Property [Unrecognized Fields]: setFeatureCompatibilityVersion rejects unrecognized fields. +UNRECOGNIZED_FIELD_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "unrecognized_field", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "CURRENT_FCV", + "confirm": True, + "unknownField": 1, + }, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="setFeatureCompatibilityVersion should reject unrecognized fields", + ), + CommandTestCase( + "misspelled_confirm", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "CURRENT_FCV", + "confrim": True, + }, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="setFeatureCompatibilityVersion should reject misspelled 'confrim' as unknown field", + ), + CommandTestCase( + "writeConcern_unknown_subfield", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "CURRENT_FCV", + "confirm": True, + "writeConcern": {"unknownField": 1}, + }, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="setFeatureCompatibilityVersion should reject unknown sub-fields in writeConcern", + ), +] + + +# Property [setParameter Rejected]: FCV cannot be set through setParameter. +SET_PARAMETER_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "via_setParameter", + command=lambda ctx: {"setParameter": 1, "featureCompatibilityVersion": "8.0"}, + error_code=ILLEGAL_OPERATION_ERROR, + msg="setFeatureCompatibilityVersion should not be settable via setParameter", + ), +] + + +# Property [Error Contains Code]: invalid version returns FCV_INVALID_VERSION_ERROR. +ERROR_CODE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "invalid_version_returns_error", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "invalid_version", + "confirm": True, + }, + error_code=FCV_INVALID_VERSION_ERROR, + msg="setFeatureCompatibilityVersion should return FCV_INVALID_VERSION_ERROR" + " for non-existent version string", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(USER_DB_REJECTED_TESTS)) +def test_setFeatureCompatibilityVersion_user_db_rejected(database_client, collection, test): + """Test setFeatureCompatibilityVersion fails on a user database.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + fcv = get_fcv(collection) + result = execute_command(collection, {"setFeatureCompatibilityVersion": fcv, "confirm": True}) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + + +@pytest.mark.parametrize("test", pytest_params(SYSTEM_DB_REJECTED_TESTS)) +def test_setFeatureCompatibilityVersion_system_db_rejected(database_client, collection, test): + """Test setFeatureCompatibilityVersion fails on local and config databases.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + fcv = get_fcv(collection) + db_name = "local" if "local" in test.id else "config" + target_collection = collection.database.client[db_name]["test"] + result = execute_command( + target_collection, {"setFeatureCompatibilityVersion": fcv, "confirm": True} + ) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + + +@pytest.mark.parametrize("test", pytest_params(UNRECOGNIZED_FIELD_TESTS)) +def test_setFeatureCompatibilityVersion_unrecognized_field(database_client, collection, test): + """Test setFeatureCompatibilityVersion rejects unrecognized fields.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + cmd = test.build_command(ctx) + cmd["setFeatureCompatibilityVersion"] = get_fcv(collection) + result = execute_admin_command(collection, cmd) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + + +@pytest.mark.parametrize("test", pytest_params(SET_PARAMETER_TESTS)) +def test_setFeatureCompatibilityVersion_set_parameter_rejected(database_client, collection, test): + """Test setFeatureCompatibilityVersion cannot be set through setParameter.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_admin_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + + +@pytest.mark.parametrize("test", pytest_params(ERROR_CODE_TESTS)) +def test_setFeatureCompatibilityVersion_invalid_version_error(database_client, collection, test): + """Test setFeatureCompatibilityVersion returns FCV_INVALID_VERSION_ERROR.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_admin_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/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_feature_not_supported.py b/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_feature_not_supported.py new file mode 100644 index 000000000..fe750cd83 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_feature_not_supported.py @@ -0,0 +1,34 @@ +"""Tests for setFeatureCompatibilityVersion feature-not-supported behavior. + +Validates that setFeatureCompatibilityVersion is classified as an admin-only +command and returns the appropriate error when the feature is not supported. +""" + +import pytest + +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import FEATURE_NOT_SUPPORTED_ERROR +from documentdb_tests.framework.executor import execute_admin_command + +pytestmark = [pytest.mark.admin, pytest.mark.no_parallel] + + +def test_setFeatureCompatibilityVersion_unsupported_returns_303(collection): + """Test setFeatureCompatibilityVersion returns error code 303 when feature not supported.""" + result = execute_admin_command( + collection, {"setFeatureCompatibilityVersion": "8.0", "confirm": True} + ) + # This test is only meaningful on engines that do not support FCV (e.g., DocumentDB). + # On engines that support FCV, this test is expected to pass/succeed instead. + if ( + isinstance(result, Exception) + and hasattr(result, "code") + and result.code == FEATURE_NOT_SUPPORTED_ERROR + ): + assertFailureCode( + result, + FEATURE_NOT_SUPPORTED_ERROR, + msg="setFeatureCompatibilityVersion should return 303 when not supported", + ) + else: + pytest.skip("Engine supports setFeatureCompatibilityVersion, skipping not-supported test") diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_version_type_validation_error.py b/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_version_type_validation_error.py new file mode 100644 index 000000000..41d9e87f8 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_version_type_validation_error.py @@ -0,0 +1,81 @@ +"""Tests for setFeatureCompatibilityVersion version field BSON type validation (error cases). + +Validates that the version field rejects all non-string BSON types +with TYPE_MISMATCH_ERROR, and rejects null with MISSING_FIELD_ERROR. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.bson_type_validator import ( + BsonType, + BsonTypeTestCase, + generate_bson_rejection_test_cases, +) +from documentdb_tests.framework.error_codes import MISSING_FIELD_ERROR, TYPE_MISMATCH_ERROR +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = [pytest.mark.admin, pytest.mark.no_parallel] + + +VERSION_TYPE_PARAM = [ + BsonTypeTestCase( + id="version_value", + msg="setFeatureCompatibilityVersion should only accept string for version", + keyword="setFeatureCompatibilityVersion", + valid_types=[BsonType.STRING], + skip_rejection_types=[BsonType.NULL], + default_error_code=TYPE_MISMATCH_ERROR, + requires={"confirm": True}, + ), +] + +VERSION_TYPE_REJECTIONS = generate_bson_rejection_test_cases(VERSION_TYPE_PARAM) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", VERSION_TYPE_REJECTIONS) +def test_setFeatureCompatibilityVersion_version_type_rejected( + collection, bson_type, sample_value, spec +): + """Test setFeatureCompatibilityVersion rejects non-string BSON types for version.""" + result = execute_admin_command( + collection, + {"setFeatureCompatibilityVersion": sample_value, "confirm": True}, + ) + assertResult( + result, + error_code=spec.expected_code(bson_type), + msg=f"setFeatureCompatibilityVersion should reject {bson_type.value} for version", + raw_res=True, + ) + + +# Property [Null Rejected]: setFeatureCompatibilityVersion rejects null for version. +VERSION_NULL_REJECTED_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "version_null_rejected", + command=lambda ctx: {"setFeatureCompatibilityVersion": None, "confirm": True}, + error_code=MISSING_FIELD_ERROR, + msg="setFeatureCompatibilityVersion should reject null for version", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(VERSION_NULL_REJECTED_TESTS)) +def test_setFeatureCompatibilityVersion_version_null_rejected(database_client, collection, test): + """Test setFeatureCompatibilityVersion rejects null for version.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_admin_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/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_version_value_validation_error.py b/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_version_value_validation_error.py new file mode 100644 index 000000000..9c68b6059 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_version_value_validation_error.py @@ -0,0 +1,117 @@ +"""Tests for setFeatureCompatibilityVersion version value validation (error cases). + +Validates that the version field rejects unsupported, malformed, and +edge-case string values. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import FCV_INVALID_VERSION_ERROR +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = [pytest.mark.admin, pytest.mark.no_parallel] + + +# Property [Invalid Version Rejected]: setFeatureCompatibilityVersion rejects +# unsupported, malformed, and edge-case version strings. +VERSION_VALUE_REJECTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "below_floor", + command=lambda ctx: {"setFeatureCompatibilityVersion": "3.0", "confirm": True}, + error_code=FCV_INVALID_VERSION_ERROR, + msg="setFeatureCompatibilityVersion should reject version below supported floor", + ), + CommandTestCase( + "above_max", + command=lambda ctx: {"setFeatureCompatibilityVersion": "99.0", "confirm": True}, + error_code=FCV_INVALID_VERSION_ERROR, + msg="setFeatureCompatibilityVersion should reject version above supported max", + ), + CommandTestCase( + "major_only", + command=lambda ctx: {"setFeatureCompatibilityVersion": "8", "confirm": True}, + error_code=FCV_INVALID_VERSION_ERROR, + msg="setFeatureCompatibilityVersion should reject major-only version string", + ), + CommandTestCase( + "full_semver", + command=lambda ctx: {"setFeatureCompatibilityVersion": "8.0.0", "confirm": True}, + error_code=FCV_INVALID_VERSION_ERROR, + msg="setFeatureCompatibilityVersion should reject full semver version string", + ), + CommandTestCase( + "leading_whitespace", + command=lambda ctx: {"setFeatureCompatibilityVersion": " 7.0", "confirm": True}, + error_code=FCV_INVALID_VERSION_ERROR, + msg="setFeatureCompatibilityVersion should reject version with leading whitespace", + ), + CommandTestCase( + "trailing_whitespace", + command=lambda ctx: {"setFeatureCompatibilityVersion": "7.0 ", "confirm": True}, + error_code=FCV_INVALID_VERSION_ERROR, + msg="setFeatureCompatibilityVersion should reject version with trailing whitespace", + ), + CommandTestCase( + "empty_string", + command=lambda ctx: {"setFeatureCompatibilityVersion": "", "confirm": True}, + error_code=FCV_INVALID_VERSION_ERROR, + msg="setFeatureCompatibilityVersion should reject empty string version", + ), + CommandTestCase( + "zero_version", + command=lambda ctx: {"setFeatureCompatibilityVersion": "0.0", "confirm": True}, + error_code=FCV_INVALID_VERSION_ERROR, + msg="setFeatureCompatibilityVersion should reject '0.0' as unsupported", + ), + CommandTestCase( + "future_version", + command=lambda ctx: {"setFeatureCompatibilityVersion": "10.0", "confirm": True}, + error_code=FCV_INVALID_VERSION_ERROR, + msg="setFeatureCompatibilityVersion should reject future unsupported version", + ), + CommandTestCase( + "non_ascii", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "\uff18.\uff10", + "confirm": True, + }, + error_code=FCV_INVALID_VERSION_ERROR, + msg="setFeatureCompatibilityVersion should reject non-ASCII version string", + ), + CommandTestCase( + "very_long_string", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "8" * 10_000, + "confirm": True, + }, + error_code=FCV_INVALID_VERSION_ERROR, + msg="setFeatureCompatibilityVersion should reject a very long version string", + ), + CommandTestCase( + "intermediate_value", + command=lambda ctx: {"setFeatureCompatibilityVersion": "7.5", "confirm": True}, + error_code=FCV_INVALID_VERSION_ERROR, + msg="setFeatureCompatibilityVersion should reject intermediate version value", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(VERSION_VALUE_REJECTION_TESTS)) +def test_setFeatureCompatibilityVersion_version_value_rejected(database_client, collection, test): + """Test setFeatureCompatibilityVersion rejects invalid version values.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_admin_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/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_writeConcern_accepted.py b/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_writeConcern_accepted.py new file mode 100644 index 000000000..c4ec4661e --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_writeConcern_accepted.py @@ -0,0 +1,132 @@ +"""Tests for setFeatureCompatibilityVersion writeConcern field acceptance. + +Validates that writeConcern accepts valid object values, null-as-omitted, +empty-doc, omission, and various numeric types for wtimeout. +""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +from .utils.setFeatureCompatibilityVersion_common import get_fcv + +pytestmark = [pytest.mark.admin, pytest.mark.no_parallel] + + +# Property [writeConcern Accepted]: setFeatureCompatibilityVersion accepts +# valid writeConcern values and various numeric types for wtimeout. +WRITE_CONCERN_ACCEPTED_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "object", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "CURRENT_FCV", + "confirm": True, + "writeConcern": {"wtimeout": 5000}, + }, + expected={"ok": 1.0}, + msg="setFeatureCompatibilityVersion should accept writeConcern as object", + ), + CommandTestCase( + "empty_object", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "CURRENT_FCV", + "confirm": True, + "writeConcern": {}, + }, + expected={"ok": 1.0}, + msg="setFeatureCompatibilityVersion should accept writeConcern as empty object", + ), + CommandTestCase( + "null_as_omitted", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "CURRENT_FCV", + "confirm": True, + "writeConcern": None, + }, + expected={"ok": 1.0}, + msg="setFeatureCompatibilityVersion should treat writeConcern=null as omitted", + ), + CommandTestCase( + "omitted", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "CURRENT_FCV", + "confirm": True, + }, + expected={"ok": 1.0}, + msg="setFeatureCompatibilityVersion should succeed when writeConcern is omitted", + ), + CommandTestCase( + "wtimeout_double", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "CURRENT_FCV", + "confirm": True, + "writeConcern": {"wtimeout": 5000.0}, + }, + expected={"ok": 1.0}, + msg="setFeatureCompatibilityVersion should accept wtimeout as double", + ), + CommandTestCase( + "wtimeout_long", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "CURRENT_FCV", + "confirm": True, + "writeConcern": {"wtimeout": Int64(5000)}, + }, + expected={"ok": 1.0}, + msg="setFeatureCompatibilityVersion should accept wtimeout as Int64", + ), + CommandTestCase( + "wtimeout_decimal_whole", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "CURRENT_FCV", + "confirm": True, + "writeConcern": {"wtimeout": Decimal128("5000")}, + }, + expected={"ok": 1.0}, + msg="setFeatureCompatibilityVersion should accept wtimeout as Decimal128", + ), + CommandTestCase( + "wtimeout_fractional_double", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "CURRENT_FCV", + "confirm": True, + "writeConcern": {"wtimeout": 5000.5}, + }, + expected={"ok": 1.0}, + msg="setFeatureCompatibilityVersion should accept wtimeout as fractional double", + ), + CommandTestCase( + "wtimeout_negative", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "CURRENT_FCV", + "confirm": True, + "writeConcern": {"wtimeout": -1}, + }, + expected={"ok": 1.0}, + msg="setFeatureCompatibilityVersion should accept wtimeout as negative value", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(WRITE_CONCERN_ACCEPTED_TESTS)) +def test_setFeatureCompatibilityVersion_writeConcern_accepted(database_client, collection, test): + """Test setFeatureCompatibilityVersion accepts valid writeConcern values.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + cmd = test.build_command(ctx) + cmd["setFeatureCompatibilityVersion"] = get_fcv(collection) + result = execute_admin_command(collection, cmd) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_writeConcern_validation_error.py b/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_writeConcern_validation_error.py new file mode 100644 index 000000000..4855b7459 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_writeConcern_validation_error.py @@ -0,0 +1,113 @@ +"""Tests for setFeatureCompatibilityVersion writeConcern field rejection (error cases). + +Validates that writeConcern rejects non-object types with TYPE_MISMATCH_ERROR. +""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import TYPE_MISMATCH_ERROR +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +from .utils.setFeatureCompatibilityVersion_common import get_fcv + +pytestmark = [pytest.mark.admin, pytest.mark.no_parallel] + + +# Property [writeConcern Rejected]: setFeatureCompatibilityVersion rejects +# non-object writeConcern types. +WRITE_CONCERN_REJECTED_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "string", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "CURRENT_FCV", + "confirm": True, + "writeConcern": "majority", + }, + error_code=TYPE_MISMATCH_ERROR, + msg="setFeatureCompatibilityVersion should reject writeConcern as string", + ), + CommandTestCase( + "int", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "CURRENT_FCV", + "confirm": True, + "writeConcern": 1, + }, + error_code=TYPE_MISMATCH_ERROR, + msg="setFeatureCompatibilityVersion should reject writeConcern as int", + ), + CommandTestCase( + "bool", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "CURRENT_FCV", + "confirm": True, + "writeConcern": True, + }, + error_code=TYPE_MISMATCH_ERROR, + msg="setFeatureCompatibilityVersion should reject writeConcern as bool", + ), + CommandTestCase( + "array", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "CURRENT_FCV", + "confirm": True, + "writeConcern": [{"w": 1}], + }, + error_code=TYPE_MISMATCH_ERROR, + msg="setFeatureCompatibilityVersion should reject writeConcern as array", + ), + CommandTestCase( + "long", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "CURRENT_FCV", + "confirm": True, + "writeConcern": Int64(1), + }, + error_code=TYPE_MISMATCH_ERROR, + msg="setFeatureCompatibilityVersion should reject writeConcern as long", + ), + CommandTestCase( + "double", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "CURRENT_FCV", + "confirm": True, + "writeConcern": 1.0, + }, + error_code=TYPE_MISMATCH_ERROR, + msg="setFeatureCompatibilityVersion should reject writeConcern as double", + ), + CommandTestCase( + "decimal128", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "CURRENT_FCV", + "confirm": True, + "writeConcern": Decimal128("1"), + }, + error_code=TYPE_MISMATCH_ERROR, + msg="setFeatureCompatibilityVersion should reject writeConcern as Decimal128", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(WRITE_CONCERN_REJECTED_TESTS)) +def test_setFeatureCompatibilityVersion_writeConcern_rejected(database_client, collection, test): + """Test setFeatureCompatibilityVersion rejects non-object writeConcern types.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + cmd = test.build_command(ctx) + cmd["setFeatureCompatibilityVersion"] = get_fcv(collection) + result = execute_admin_command(collection, cmd) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_smoke_setFeatureCompatibilityVersion.py b/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_smoke_setFeatureCompatibilityVersion.py index f343fb194..0c381df45 100644 --- a/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_smoke_setFeatureCompatibilityVersion.py +++ b/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_smoke_setFeatureCompatibilityVersion.py @@ -9,33 +9,23 @@ from documentdb_tests.framework.assertions import assertSuccessPartial from documentdb_tests.framework.executor import execute_admin_command +from .utils.setFeatureCompatibilityVersion_common import get_fcv + pytestmark = [pytest.mark.smoke, pytest.mark.no_parallel] def test_smoke_setFeatureCompatibilityVersion(collection): """Test basic setFeatureCompatibilityVersion behavior.""" - get_result = execute_admin_command( - collection, {"getParameter": 1, "featureCompatibilityVersion": 1} - ) - - original_fcv = "8.2" - if not isinstance(get_result, Exception): - fcv_data = get_result.get("featureCompatibilityVersion", {}) - if isinstance(fcv_data, dict): - original_fcv = fcv_data.get("version", "8.2") - else: - original_fcv = str(fcv_data) - - new_fcv = "8.2" if original_fcv != "8.2" else "8.0" + original_fcv = get_fcv(collection) + new_fcv = "8.0" if original_fcv != "8.0" else "8.2" result = execute_admin_command( collection, {"setFeatureCompatibilityVersion": new_fcv, "confirm": True} ) - - expected = {"ok": 1.0} assertSuccessPartial( - result, expected, msg="Should support setFeatureCompatibilityVersion command" + result, + {"ok": 1.0}, + msg="setFeatureCompatibilityVersion should succeed with a valid version change", ) - execute_admin_command( collection, {"setFeatureCompatibilityVersion": original_fcv, "confirm": True} ) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/utils/__init__.py b/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/utils/setFeatureCompatibilityVersion_common.py b/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/utils/setFeatureCompatibilityVersion_common.py new file mode 100644 index 000000000..efe74a378 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/utils/setFeatureCompatibilityVersion_common.py @@ -0,0 +1,16 @@ +"""Utilities for setFeatureCompatibilityVersion tests.""" + +from documentdb_tests.framework.executor import execute_admin_command + + +def get_fcv(collection): + """Read the current FCV via getParameter.""" + result = execute_admin_command( + collection, {"getParameter": 1, "featureCompatibilityVersion": 1} + ) + if isinstance(result, Exception): + return "8.2" + fcv_data = result.get("featureCompatibilityVersion", {}) + if isinstance(fcv_data, dict): + return fcv_data.get("version", "8.2") + return str(fcv_data) diff --git a/documentdb_tests/framework/error_codes.py b/documentdb_tests/framework/error_codes.py index 1456e8ed9..3424676d0 100644 --- a/documentdb_tests/framework/error_codes.py +++ b/documentdb_tests/framework/error_codes.py @@ -65,6 +65,7 @@ CHANGE_STREAM_HISTORY_LOST_ERROR = 286 NO_QUERY_EXECUTION_PLANS_ERROR = 291 QUERY_EXCEEDED_MEMORY_NO_DISK_USE_ERROR = 292 +FEATURE_NOT_SUPPORTED_ERROR = 303 API_VERSION_ERROR = 322 API_STRICT_ERROR = 323 CANNOT_CONVERT_INDEX_TO_UNIQUE_ERROR = 359 @@ -457,6 +458,7 @@ MODULO_BY_ZERO_V2_ERROR = 4848403 API_VERSION_REQUIRED_ERROR = 4886600 LET_FIELD_REFERENCE_IN_VALUE_ERROR = 4890500 +FCV_INVALID_VERSION_ERROR = 4926900 COLLECTION_UUID_NOT_SUPPORTED_AGNOSTIC_ERROR = 4928901 ARRAY_TO_OBJECT_NULL_BYTE_PAIR_KEY_ERROR = 4940400 ARRAY_TO_OBJECT_NULL_BYTE_KV_KEY_ERROR = 4940401 @@ -529,6 +531,7 @@ WILDCARD_STRING_TYPE_ERROR = 7246202 OUT_TIMESERIES_COLLECTION_TYPE_ERROR = 7268700 MISSING_COMPACT_TOKEN_ERROR = 7294900 +FCV_CONFIRM_REQUIRED_ERROR = 7369100 OUT_TIMESERIES_OPTIONS_MISMATCH_ERROR = 7406103 SORT_DUPLICATE_KEY_ERROR = 7472500 N_ACCUMULATOR_INVALID_N_ERROR = 7548606 From 5ba1f1547d05811e1c26c967934433855242ff50 Mon Sep 17 00:00:00 2001 From: "Alina (Xi) Li" <96995091+alinaliBQ@users.noreply.github.com> Date: Mon, 6 Jul 2026 19:01:21 -0700 Subject: [PATCH 09/35] Add `setUserWriteBlockMode` tests (#658) Signed-off-by: Alina (Xi) Li Signed-off-by: RyanGarfinkel <113050972+RyanGarfinkel@users.noreply.github.com> --- .../setUserWriteBlockMode/__init__.py | 0 ...tUserWriteBlockMode_argument_validation.py | 150 ++++++++++++++ ...est_setUserWriteBlockMode_core_behavior.py | 141 +++++++++++++ .../test_setUserWriteBlockMode_errors.py | 108 ++++++++++ .../test_setUserWriteBlockMode_success.py | 175 ++++++++++++++++ ...rWriteBlockMode_write_block_enforcement.py | 194 ++++++++++++++++++ .../setUserWriteBlockMode/utils/__init__.py | 0 .../utils/write_block_helpers.py | 30 +++ .../administration/commands/utils/__init__.py | 0 .../commands/utils/admin_test_case.py | 49 +++++ documentdb_tests/framework/error_codes.py | 1 + 11 files changed, 848 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/setUserWriteBlockMode/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/setUserWriteBlockMode/test_setUserWriteBlockMode_argument_validation.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/setUserWriteBlockMode/test_setUserWriteBlockMode_core_behavior.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/setUserWriteBlockMode/test_setUserWriteBlockMode_errors.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/setUserWriteBlockMode/test_setUserWriteBlockMode_success.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/setUserWriteBlockMode/test_setUserWriteBlockMode_write_block_enforcement.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/setUserWriteBlockMode/utils/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/setUserWriteBlockMode/utils/write_block_helpers.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/utils/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/utils/admin_test_case.py diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/setUserWriteBlockMode/__init__.py b/documentdb_tests/compatibility/tests/system/administration/commands/setUserWriteBlockMode/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/setUserWriteBlockMode/test_setUserWriteBlockMode_argument_validation.py b/documentdb_tests/compatibility/tests/system/administration/commands/setUserWriteBlockMode/test_setUserWriteBlockMode_argument_validation.py new file mode 100644 index 000000000..b29c2c161 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/setUserWriteBlockMode/test_setUserWriteBlockMode_argument_validation.py @@ -0,0 +1,150 @@ +"""Tests for setUserWriteBlockMode argument validation errors. + +Validates type rejection for the global and reason fields, missing required fields, +invalid enum values, and unrecognized fields. +""" + +from __future__ import annotations + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, +) +from documentdb_tests.compatibility.tests.system.administration.commands.setUserWriteBlockMode.utils.write_block_helpers import ( # noqa: E501 + force_disable_write_block, +) +from documentdb_tests.compatibility.tests.system.administration.commands.utils.admin_test_case import ( # noqa: E501 + AdminTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + MISSING_FIELD_ERROR, + TYPE_MISMATCH_ERROR, + UNRECOGNIZED_COMMAND_FIELD_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import FLOAT_INFINITY, FLOAT_NAN + +pytestmark = [pytest.mark.admin, pytest.mark.no_parallel, pytest.mark.requires(cluster_admin=True)] + + +@pytest.fixture(autouse=True) +def _manage_write_block(collection): + """Ensure write block is disabled before and after each test.""" + force_disable_write_block(collection) + yield + force_disable_write_block(collection) + + +# Property [Global Field Type Rejection]: setUserWriteBlockMode rejects all non-boolean types +# for the global field with no coercion. +GLOBAL_TYPE_REJECTION_TESTS: list[AdminTestCase] = [ + AdminTestCase( + f"global_type_{tid}", + command=lambda ctx, v=val: {"setUserWriteBlockMode": 1, "global": v}, + error_code=error, + msg=f"setUserWriteBlockMode should reject {tid} for global field", + ) + for tid, val, error in [ + ("int32_1", 1, TYPE_MISMATCH_ERROR), + ("int32_0", 0, TYPE_MISMATCH_ERROR), + ("double_1", 1.0, TYPE_MISMATCH_ERROR), + ("double_0", 0.0, TYPE_MISMATCH_ERROR), + ("int64", Int64(1), TYPE_MISMATCH_ERROR), + ("decimal128", Decimal128("1"), TYPE_MISMATCH_ERROR), + ("nan", FLOAT_NAN, TYPE_MISMATCH_ERROR), + ("infinity", FLOAT_INFINITY, TYPE_MISMATCH_ERROR), + ("negative_infinity", float("-inf"), TYPE_MISMATCH_ERROR), + ("negative_zero", -0.0, TYPE_MISMATCH_ERROR), + ("string", "true", TYPE_MISMATCH_ERROR), + ("array", [], TYPE_MISMATCH_ERROR), + ("object", {}, TYPE_MISMATCH_ERROR), + ] +] + +# Property [Missing Global Field]: setUserWriteBlockMode requires the global field. +# Null is treated as missing. +MISSING_GLOBAL_TESTS: list[AdminTestCase] = [ + AdminTestCase( + "missing_global", + command=lambda ctx: {"setUserWriteBlockMode": 1}, + error_code=MISSING_FIELD_ERROR, + msg="setUserWriteBlockMode should require the global field", + ), + AdminTestCase( + "global_null_treated_as_missing", + command=lambda ctx: {"setUserWriteBlockMode": 1, "global": None}, + error_code=MISSING_FIELD_ERROR, + msg="setUserWriteBlockMode should treat null global as missing", + ), +] + +# Property [Reason Field Type Rejection]: setUserWriteBlockMode rejects non-string types for +# the reason field. +REASON_TYPE_REJECTION_TESTS: list[AdminTestCase] = [ + AdminTestCase( + f"reason_type_{tid}", + command=lambda ctx, v=val: { + "setUserWriteBlockMode": 1, + "global": True, + "reason": v, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"setUserWriteBlockMode should reject {tid} for reason field", + ) + for tid, val in [ + ("int", 1), + ("bool", True), + ("array", []), + ("object", {}), + ] +] + +# Property [Reason Field Invalid Enum]: setUserWriteBlockMode rejects unrecognized reason +# strings. +REASON_INVALID_ENUM_TESTS: list[AdminTestCase] = [ + AdminTestCase( + "reason_invalid_enum", + command=lambda ctx: { + "setUserWriteBlockMode": 1, + "global": True, + "reason": "InvalidReason", + }, + error_code=BAD_VALUE_ERROR, + msg="setUserWriteBlockMode should reject unrecognized reason enum value", + ), +] + +# Property [Unrecognized Fields]: setUserWriteBlockMode rejects unknown fields. +UNRECOGNIZED_FIELD_TESTS: list[AdminTestCase] = [ + AdminTestCase( + "unrecognized_field", + command=lambda ctx: { + "setUserWriteBlockMode": 1, + "global": False, + "unknownField": 1, + }, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="setUserWriteBlockMode should reject unrecognized fields", + ), +] + +ARGUMENT_ERROR_TESTS: list[AdminTestCase] = ( + GLOBAL_TYPE_REJECTION_TESTS + + MISSING_GLOBAL_TESTS + + REASON_TYPE_REJECTION_TESTS + + REASON_INVALID_ENUM_TESTS + + UNRECOGNIZED_FIELD_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ARGUMENT_ERROR_TESTS)) +def test_setUserWriteBlockMode_argument_error(collection, test): + """Test setUserWriteBlockMode rejects invalid arguments.""" + ctx = CommandContext.from_collection(collection) + result = execute_admin_command(collection, test.build_command(ctx)) + assertFailureCode(result, test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/setUserWriteBlockMode/test_setUserWriteBlockMode_core_behavior.py b/documentdb_tests/compatibility/tests/system/administration/commands/setUserWriteBlockMode/test_setUserWriteBlockMode_core_behavior.py new file mode 100644 index 000000000..9575ddacb --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/setUserWriteBlockMode/test_setUserWriteBlockMode_core_behavior.py @@ -0,0 +1,141 @@ +"""Tests for setUserWriteBlockMode core behavior. + +Validates enable/disable semantics, idempotent behavior, and state restoration. +""" + +import pytest + +from documentdb_tests.compatibility.tests.system.administration.commands.setUserWriteBlockMode.utils.write_block_helpers import ( # noqa: E501 + force_disable_write_block, +) +from documentdb_tests.framework.assertions import assertSuccessPartial +from documentdb_tests.framework.executor import execute_admin_command, execute_command + +pytestmark = [pytest.mark.admin, pytest.mark.no_parallel, pytest.mark.requires(cluster_admin=True)] + + +@pytest.fixture(autouse=True) +def _manage_write_block(collection): + """Ensure write block is disabled before and after each test.""" + force_disable_write_block(collection) + yield + force_disable_write_block(collection) + + +# Property [Idempotent Disable]: disabling write block when no block is active succeeds and +# writes are allowed. +def test_setUserWriteBlockMode_disable_when_no_block_active(collection): + """Test setUserWriteBlockMode global:false when no block is active leaves writes allowed.""" + execute_admin_command(collection, {"setUserWriteBlockMode": 1, "global": False}) + result = execute_command( + collection, {"insert": collection.name, "documents": [{"_id": "no_block_write"}]} + ) + assertSuccessPartial( + result, + {"ok": 1.0, "n": 1}, + msg="setUserWriteBlockMode should leave writes allowed when disabling with no active block", + ) + + +# Property [Write Restoration]: writes succeed after disabling a previously active block. +# (Blocking of writes while a block is active is covered by +# test_setUserWriteBlockMode_write_block_enforcement.py.) +def test_setUserWriteBlockMode_enable_disable_restores_writes(collection): + """Test setUserWriteBlockMode enable then disable allows writes again.""" + execute_admin_command(collection, {"setUserWriteBlockMode": 1, "global": True}) + execute_admin_command(collection, {"setUserWriteBlockMode": 1, "global": False}) + result = execute_command( + collection, {"insert": collection.name, "documents": [{"_id": "restore_test"}]} + ) + assertSuccessPartial( + result, + {"ok": 1.0, "n": 1}, + msg="setUserWriteBlockMode should allow writes after block is disabled", + ) + + +# Property [Repeated Toggle]: toggling write block multiple times does not produce errors. +def test_setUserWriteBlockMode_toggle_multiple_times(collection): + """Test setUserWriteBlockMode toggling on and off multiple times succeeds.""" + execute_admin_command(collection, {"setUserWriteBlockMode": 1, "global": True}) + execute_admin_command(collection, {"setUserWriteBlockMode": 1, "global": False}) + execute_admin_command(collection, {"setUserWriteBlockMode": 1, "global": True}) + execute_admin_command(collection, {"setUserWriteBlockMode": 1, "global": False}) + result = execute_admin_command(collection, {"setUserWriteBlockMode": 1, "global": True}) + assertSuccessPartial( + result, + {"ok": 1.0}, + msg="setUserWriteBlockMode should succeed after repeated toggling", + ) + + +# Property [Idempotent Enable]: re-enabling with same default reason is idempotent. +def test_setUserWriteBlockMode_enable_idempotent_same_reason(collection): + """Test setUserWriteBlockMode re-enable with same reason is idempotent.""" + execute_admin_command(collection, {"setUserWriteBlockMode": 1, "global": True}) + result = execute_admin_command(collection, {"setUserWriteBlockMode": 1, "global": True}) + assertSuccessPartial( + result, + {"ok": 1.0}, + msg="setUserWriteBlockMode should be idempotent when re-enabling with same reason", + ) + + +# Property [Same Explicit Reason Idempotent]: re-enabling with same explicit reason succeeds. +def test_setUserWriteBlockMode_same_reason_unspecified_idempotent(collection): + """Test setUserWriteBlockMode re-enable with same reason Unspecified is idempotent.""" + execute_admin_command( + collection, + {"setUserWriteBlockMode": 1, "global": True, "reason": "Unspecified"}, + ) + result = execute_admin_command( + collection, + {"setUserWriteBlockMode": 1, "global": True, "reason": "Unspecified"}, + ) + assertSuccessPartial( + result, + {"ok": 1.0}, + msg="setUserWriteBlockMode should be idempotent with same explicit reason", + ) + + +def test_setUserWriteBlockMode_same_reason_disk_threshold_idempotent(collection): + """Test setUserWriteBlockMode re-enable with same reason DiskUseThresholdExceeded.""" + execute_admin_command( + collection, + {"setUserWriteBlockMode": 1, "global": True, "reason": "DiskUseThresholdExceeded"}, + ) + result = execute_admin_command( + collection, + {"setUserWriteBlockMode": 1, "global": True, "reason": "DiskUseThresholdExceeded"}, + ) + assertSuccessPartial( + result, + {"ok": 1.0}, + msg="setUserWriteBlockMode should be idempotent with same explicit reason", + ) + + +def test_setUserWriteBlockMode_same_reason_cluster_migration_idempotent(collection): + """Test setUserWriteBlockMode re-enable with same reason ClusterToClusterMigrationInProgress.""" + execute_admin_command( + collection, + { + "setUserWriteBlockMode": 1, + "global": True, + "reason": "ClusterToClusterMigrationInProgress", + }, + ) + result = execute_admin_command( + collection, + { + "setUserWriteBlockMode": 1, + "global": True, + "reason": "ClusterToClusterMigrationInProgress", + }, + ) + assertSuccessPartial( + result, + {"ok": 1.0}, + msg="setUserWriteBlockMode should be idempotent with same explicit reason", + ) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/setUserWriteBlockMode/test_setUserWriteBlockMode_errors.py b/documentdb_tests/compatibility/tests/system/administration/commands/setUserWriteBlockMode/test_setUserWriteBlockMode_errors.py new file mode 100644 index 000000000..18eb8cfb6 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/setUserWriteBlockMode/test_setUserWriteBlockMode_errors.py @@ -0,0 +1,108 @@ +"""Tests for setUserWriteBlockMode error cases. + +Validates mismatched reason errors when changing the reason on an active block. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.system.administration.commands.setUserWriteBlockMode.utils.write_block_helpers import ( # noqa: E501 + force_disable_write_block, +) +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import ILLEGAL_OPERATION_ERROR +from documentdb_tests.framework.executor import execute_admin_command + +pytestmark = [pytest.mark.admin, pytest.mark.no_parallel, pytest.mark.requires(cluster_admin=True)] + + +@pytest.fixture(autouse=True) +def _manage_write_block(collection): + """Ensure write block is disabled before and after each test.""" + force_disable_write_block(collection) + yield + force_disable_write_block(collection) + + +# Property [Mismatched Reason on Enable]: re-enabling with a different reason fails. +def test_setUserWriteBlockMode_enable_mismatched_reason_fails(collection): + """Test setUserWriteBlockMode re-enable with different reason fails.""" + execute_admin_command( + collection, + {"setUserWriteBlockMode": 1, "global": True, "reason": "Unspecified"}, + ) + result = execute_admin_command( + collection, + { + "setUserWriteBlockMode": 1, + "global": True, + "reason": "ClusterToClusterMigrationInProgress", + }, + ) + assertFailureCode( + result, + ILLEGAL_OPERATION_ERROR, + msg="setUserWriteBlockMode should reject mismatched reason on re-enable", + ) + + +# Property [Mismatched Reason on Disable]: disabling with a different reason than the active +# block fails. +def test_setUserWriteBlockMode_disable_mismatched_reason_fails(collection): + """Test setUserWriteBlockMode disable with different reason fails.""" + execute_admin_command( + collection, + {"setUserWriteBlockMode": 1, "global": True, "reason": "Unspecified"}, + ) + result = execute_admin_command( + collection, + { + "setUserWriteBlockMode": 1, + "global": False, + "reason": "ClusterToClusterMigrationInProgress", + }, + ) + assertFailureCode( + result, + ILLEGAL_OPERATION_ERROR, + msg="setUserWriteBlockMode should reject mismatched reason on disable", + ) + + +# Property [Mismatched Reason on Enable]: re-enabling with DiskUseThresholdExceeded when a +# different reason is active fails. +def test_setUserWriteBlockMode_enable_mismatched_reason_disk_threshold_fails(collection): + """Test setUserWriteBlockMode re-enable with DiskUseThresholdExceeded over another reason.""" + execute_admin_command( + collection, + {"setUserWriteBlockMode": 1, "global": True, "reason": "Unspecified"}, + ) + result = execute_admin_command( + collection, + {"setUserWriteBlockMode": 1, "global": True, "reason": "DiskUseThresholdExceeded"}, + ) + assertFailureCode( + result, + ILLEGAL_OPERATION_ERROR, + msg="setUserWriteBlockMode should reject mismatched reason on re-enable", + ) + + +# Property [Mismatched Reason on Disable]: disabling with DiskUseThresholdExceeded when a +# different reason is active fails. +def test_setUserWriteBlockMode_disable_mismatched_reason_disk_threshold_fails(collection): + """Test setUserWriteBlockMode disable with DiskUseThresholdExceeded over another reason.""" + execute_admin_command( + collection, + {"setUserWriteBlockMode": 1, "global": True, "reason": "Unspecified"}, + ) + result = execute_admin_command( + collection, + {"setUserWriteBlockMode": 1, "global": False, "reason": "DiskUseThresholdExceeded"}, + ) + assertFailureCode( + result, + ILLEGAL_OPERATION_ERROR, + msg="setUserWriteBlockMode should reject mismatched reason on disable", + ) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/setUserWriteBlockMode/test_setUserWriteBlockMode_success.py b/documentdb_tests/compatibility/tests/system/administration/commands/setUserWriteBlockMode/test_setUserWriteBlockMode_success.py new file mode 100644 index 000000000..ac7526a4b --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/setUserWriteBlockMode/test_setUserWriteBlockMode_success.py @@ -0,0 +1,175 @@ +"""Tests for setUserWriteBlockMode success cases. + +Validates argument acceptance, read operations not blocked while active, +and write operations succeeding when block is disabled. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, +) +from documentdb_tests.compatibility.tests.system.administration.commands.setUserWriteBlockMode.utils.write_block_helpers import ( # noqa: E501 + force_disable_write_block, +) +from documentdb_tests.compatibility.tests.system.administration.commands.utils.admin_test_case import ( # noqa: E501 + AdminTestCase, +) +from documentdb_tests.framework.assertions import assertSuccessPartial +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = [pytest.mark.admin, pytest.mark.no_parallel, pytest.mark.requires(cluster_admin=True)] + + +@pytest.fixture(autouse=True) +def _manage_write_block(collection): + """Ensure write block is disabled before and after each test.""" + force_disable_write_block(collection) + yield + force_disable_write_block(collection) + + +# Global-field boolean acceptance (global:true/false) and valid reason enum acceptance are +# covered by test_setUserWriteBlockMode_core_behavior.py (enable/disable and idempotent-reason +# tests), so they are not duplicated here. + +# Property [Reason Field Optional]: the reason field can be null. reason_null checks the command +# accepts null; reason_null_treated_as_omitted checks null behaves like an omitted reason, proven +# via a write (a no-reason disable only matches, restoring writes, if null was treated as omitted). +REASON_OPTIONAL_TESTS: list[AdminTestCase] = [ + AdminTestCase( + "reason_null", + command=lambda ctx: {"setUserWriteBlockMode": 1, "global": True, "reason": None}, + expected={"ok": 1.0}, + msg="setUserWriteBlockMode should accept null reason", + ), + AdminTestCase( + "reason_null_treated_as_omitted", + use_admin=False, + partial_success=True, + docs=[], + pre_command=lambda c: ( + execute_admin_command(c, {"setUserWriteBlockMode": 1, "global": True, "reason": None}), + execute_admin_command(c, {"setUserWriteBlockMode": 1, "global": False}), + ), + command=lambda ctx: { + "insert": ctx.collection, + "documents": [{"_id": "null_reason_write"}], + }, + expected={"ok": 1.0, "n": 1}, + msg="setUserWriteBlockMode should treat null reason as omitted so writes resume", + ), +] + +# Property [Read Operations Not Blocked]: read operations succeed while the block is active. +READ_NOT_BLOCKED_TESTS: list[AdminTestCase] = [ + AdminTestCase( + "read_find", + use_admin=False, + partial_success=True, + docs=[{"_id": "read_doc", "x": 1}], + pre_command=lambda c: execute_admin_command( + c, {"setUserWriteBlockMode": 1, "global": True} + ), + command=lambda ctx: {"find": ctx.collection, "filter": {"_id": "read_doc"}}, + expected={"ok": 1.0, "cursor": {"firstBatch": [{"_id": "read_doc", "x": 1}]}}, + msg="setUserWriteBlockMode should not block find while active", + ), + AdminTestCase( + "read_aggregate", + use_admin=False, + partial_success=True, + docs=[{"_id": "agg_doc", "x": 5}], + pre_command=lambda c: execute_admin_command( + c, {"setUserWriteBlockMode": 1, "global": True} + ), + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [{"$match": {"_id": "agg_doc"}}], + "cursor": {}, + }, + expected={"ok": 1.0, "cursor": {"firstBatch": [{"_id": "agg_doc", "x": 5}]}}, + msg="setUserWriteBlockMode should not block aggregate while active", + ), + AdminTestCase( + "read_count", + use_admin=False, + partial_success=True, + docs=[{"_id": "c1"}, {"_id": "c2"}, {"_id": "c3"}], + pre_command=lambda c: execute_admin_command( + c, {"setUserWriteBlockMode": 1, "global": True} + ), + command=lambda ctx: {"count": ctx.collection}, + expected={"ok": 1.0, "n": 3}, + msg="setUserWriteBlockMode should not block count while active", + ), + AdminTestCase( + "read_distinct", + use_admin=False, + partial_success=True, + docs=[{"_id": "d1", "x": 1}, {"_id": "d2", "x": 2}, {"_id": "d3", "x": 1}], + pre_command=lambda c: execute_admin_command( + c, {"setUserWriteBlockMode": 1, "global": True} + ), + command=lambda ctx: {"distinct": ctx.collection, "key": "x"}, + expected={"ok": 1.0, "values": [1, 2]}, + msg="setUserWriteBlockMode should not block distinct while active", + ), +] + +# Property [Writes Succeed When Disabled]: write operations succeed when no block is active. +WRITE_SUCCEEDS_TESTS: list[AdminTestCase] = [ + AdminTestCase( + "write_insert_no_block", + use_admin=False, + partial_success=True, + docs=[], + command=lambda ctx: {"insert": ctx.collection, "documents": [{"_id": "new_doc", "v": 42}]}, + expected={"ok": 1.0, "n": 1}, + msg="setUserWriteBlockMode should allow insert when block is not active", + ), + AdminTestCase( + "write_update_no_block", + use_admin=False, + partial_success=True, + docs=[{"_id": "target", "x": 1}], + command=lambda ctx: { + "update": ctx.collection, + "updates": [{"q": {"_id": "target"}, "u": {"$set": {"x": 99}}}], + }, + expected={"ok": 1.0, "n": 1, "nModified": 1}, + msg="setUserWriteBlockMode should allow update when block is not active", + ), + AdminTestCase( + "write_delete_no_block", + use_admin=False, + partial_success=True, + docs=[{"_id": "target"}], + command=lambda ctx: { + "delete": ctx.collection, + "deletes": [{"q": {"_id": "target"}, "limit": 1}], + }, + expected={"ok": 1.0, "n": 1}, + msg="setUserWriteBlockMode should allow delete when block is not active", + ), +] + +SUCCESS_TESTS: list[AdminTestCase] = ( + REASON_OPTIONAL_TESTS + READ_NOT_BLOCKED_TESTS + WRITE_SUCCEEDS_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(SUCCESS_TESTS)) +def test_setUserWriteBlockMode_success(database_client, collection, test): + """Test setUserWriteBlockMode success cases.""" + collection = test.prepare(database_client, collection) + test.run_pre_command(collection) + ctx = CommandContext.from_collection(collection) + if test.use_admin: + result = execute_admin_command(collection, test.build_command(ctx)) + else: + result = execute_command(collection, test.build_command(ctx)) + assertSuccessPartial(result, test.expected, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/setUserWriteBlockMode/test_setUserWriteBlockMode_write_block_enforcement.py b/documentdb_tests/compatibility/tests/system/administration/commands/setUserWriteBlockMode/test_setUserWriteBlockMode_write_block_enforcement.py new file mode 100644 index 000000000..c6fce6074 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/setUserWriteBlockMode/test_setUserWriteBlockMode_write_block_enforcement.py @@ -0,0 +1,194 @@ +"""Tests for setUserWriteBlockMode write block enforcement errors. + +Validates that write operations are rejected while the block is active. +""" + +from __future__ import annotations + +import pytest +from pymongo import IndexModel + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, +) +from documentdb_tests.compatibility.tests.system.administration.commands.setUserWriteBlockMode.utils.write_block_helpers import ( # noqa: E501 + force_disable_write_block, +) +from documentdb_tests.compatibility.tests.system.administration.commands.utils.admin_test_case import ( # noqa: E501 + AdminTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import USER_WRITES_BLOCKED_ERROR +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = [pytest.mark.admin, pytest.mark.no_parallel, pytest.mark.requires(cluster_admin=True)] + + +@pytest.fixture(autouse=True) +def _manage_write_block(collection): + """Ensure write block is disabled before and after each test.""" + force_disable_write_block(collection) + yield + force_disable_write_block(collection) + + +# Property [Write Operations Blocked]: all write operations are rejected while the block is +# active. +WRITE_BLOCKED_TESTS: list[AdminTestCase] = [ + AdminTestCase( + "blocked_insert", + use_admin=False, + docs=[{"_id": "seed", "a": 1}], + indexes=[IndexModel([("a", 1)], name="a_1")], + pre_command=lambda c: execute_admin_command( + c, {"setUserWriteBlockMode": 1, "global": True} + ), + command=lambda ctx: {"insert": ctx.collection, "documents": [{"_id": "blocked"}]}, + error_code=USER_WRITES_BLOCKED_ERROR, + msg="setUserWriteBlockMode should block insert while active", + ), + AdminTestCase( + "blocked_update", + use_admin=False, + docs=[{"_id": "seed", "a": 1}], + indexes=[IndexModel([("a", 1)], name="a_1")], + pre_command=lambda c: execute_admin_command( + c, {"setUserWriteBlockMode": 1, "global": True} + ), + command=lambda ctx: { + "update": ctx.collection, + "updates": [{"q": {}, "u": {"$set": {"x": 2}}}], + }, + error_code=USER_WRITES_BLOCKED_ERROR, + msg="setUserWriteBlockMode should block update while active", + ), + AdminTestCase( + "blocked_delete", + use_admin=False, + docs=[{"_id": "seed", "a": 1}], + indexes=[IndexModel([("a", 1)], name="a_1")], + pre_command=lambda c: execute_admin_command( + c, {"setUserWriteBlockMode": 1, "global": True} + ), + command=lambda ctx: { + "delete": ctx.collection, + "deletes": [{"q": {}, "limit": 1}], + }, + error_code=USER_WRITES_BLOCKED_ERROR, + msg="setUserWriteBlockMode should block delete while active", + ), + AdminTestCase( + "blocked_findAndModify_update", + use_admin=False, + docs=[{"_id": "seed", "a": 1}], + pre_command=lambda c: execute_admin_command( + c, {"setUserWriteBlockMode": 1, "global": True} + ), + command=lambda ctx: { + "findAndModify": ctx.collection, + "query": {}, + "update": {"$set": {"x": 2}}, + }, + error_code=USER_WRITES_BLOCKED_ERROR, + msg="setUserWriteBlockMode should block findAndModify update while active", + ), + AdminTestCase( + "blocked_findAndModify_remove", + use_admin=False, + docs=[{"_id": "seed", "a": 1}], + pre_command=lambda c: execute_admin_command( + c, {"setUserWriteBlockMode": 1, "global": True} + ), + command=lambda ctx: { + "findAndModify": ctx.collection, + "query": {}, + "remove": True, + }, + error_code=USER_WRITES_BLOCKED_ERROR, + msg="setUserWriteBlockMode should block findAndModify remove while active", + ), + AdminTestCase( + "blocked_createIndexes", + use_admin=False, + docs=[{"_id": "seed", "a": 1}], + pre_command=lambda c: execute_admin_command( + c, {"setUserWriteBlockMode": 1, "global": True} + ), + command=lambda ctx: { + "createIndexes": ctx.collection, + "indexes": [{"key": {"blocked_field": 1}, "name": "blocked_field_1"}], + }, + error_code=USER_WRITES_BLOCKED_ERROR, + msg="setUserWriteBlockMode should block createIndexes while active", + ), + AdminTestCase( + "blocked_dropIndexes", + use_admin=False, + docs=[{"_id": "seed", "a": 1}], + indexes=[IndexModel([("a", 1)], name="a_1")], + pre_command=lambda c: execute_admin_command( + c, {"setUserWriteBlockMode": 1, "global": True} + ), + command=lambda ctx: {"dropIndexes": ctx.collection, "index": "a_1"}, + error_code=USER_WRITES_BLOCKED_ERROR, + msg="setUserWriteBlockMode should block dropIndexes while active", + ), + AdminTestCase( + "blocked_drop_collection", + use_admin=False, + docs=[{"_id": "seed"}], + pre_command=lambda c: execute_admin_command( + c, {"setUserWriteBlockMode": 1, "global": True} + ), + command=lambda ctx: {"drop": ctx.collection}, + error_code=USER_WRITES_BLOCKED_ERROR, + msg="setUserWriteBlockMode should block drop collection while active", + ), + AdminTestCase( + "blocked_create_collection", + use_admin=False, + docs=[{"_id": "seed"}], + pre_command=lambda c: execute_admin_command( + c, {"setUserWriteBlockMode": 1, "global": True} + ), + command=lambda ctx: {"create": f"{ctx.collection}_blocked_new"}, + error_code=USER_WRITES_BLOCKED_ERROR, + msg="setUserWriteBlockMode should block create collection while active", + ), + AdminTestCase( + "blocked_dropDatabase", + use_admin=False, + docs=[{"_id": "seed"}], + pre_command=lambda c: execute_admin_command( + c, {"setUserWriteBlockMode": 1, "global": True} + ), + command=lambda ctx: {"dropDatabase": 1}, + error_code=USER_WRITES_BLOCKED_ERROR, + msg="setUserWriteBlockMode should block dropDatabase while active", + ), + AdminTestCase( + "blocked_batch_insert", + use_admin=False, + docs=[{"_id": "seed"}], + pre_command=lambda c: execute_admin_command( + c, {"setUserWriteBlockMode": 1, "global": True} + ), + command=lambda ctx: { + "insert": ctx.collection, + "documents": [{"_id": "bulk1"}, {"_id": "bulk2"}], + }, + error_code=USER_WRITES_BLOCKED_ERROR, + msg="setUserWriteBlockMode should block multi-document insert while active", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(WRITE_BLOCKED_TESTS)) +def test_setUserWriteBlockMode_blocked(database_client, collection, test): + """Test setUserWriteBlockMode blocks write operations while active.""" + collection = test.prepare(database_client, collection) + test.run_pre_command(collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertFailureCode(result, test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/setUserWriteBlockMode/utils/__init__.py b/documentdb_tests/compatibility/tests/system/administration/commands/setUserWriteBlockMode/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/setUserWriteBlockMode/utils/write_block_helpers.py b/documentdb_tests/compatibility/tests/system/administration/commands/setUserWriteBlockMode/utils/write_block_helpers.py new file mode 100644 index 000000000..e3f442593 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/setUserWriteBlockMode/utils/write_block_helpers.py @@ -0,0 +1,30 @@ +"""Shared utilities for setUserWriteBlockMode tests.""" + + +def force_disable_write_block(collection): + """Force-disable write block regardless of current reason. + + Disabling requires the same ``reason`` that was used to enable the block: + a no-reason disable fails with IllegalOperation ("Cannot release user + writes critical section with different reason than the already-set + reason") when the block was enabled with an explicit reason. So we first + try the no-reason disable (works when enabled with no reason or when + already disabled) and then fall back to each known reason until one + matches the reason the block was enabled with. + """ + admin = collection.database.client.admin + try: + admin.command({"setUserWriteBlockMode": 1, "global": False}) + return + except Exception: + pass + for reason in [ + "Unspecified", + "ClusterToClusterMigrationInProgress", + "DiskUseThresholdExceeded", + ]: + try: + admin.command({"setUserWriteBlockMode": 1, "global": False, "reason": reason}) + return + except Exception: + continue diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/utils/__init__.py b/documentdb_tests/compatibility/tests/system/administration/commands/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/utils/admin_test_case.py b/documentdb_tests/compatibility/tests/system/administration/commands/utils/admin_test_case.py new file mode 100644 index 000000000..d35be87bb --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/utils/admin_test_case.py @@ -0,0 +1,49 @@ +"""Shared test case for administration command tests.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from pymongo.collection import Collection + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) + + +@dataclass(frozen=True) +class AdminTestCase(CommandTestCase): + """Test case for administration command tests. + + Extends CommandTestCase with fields for admin-specific execution: + + Attributes: + use_admin: If True (the default), execute the command against + the admin database via ``execute_admin_command``. If False, + execute against the test database via ``execute_command``. + pre_command: Optional callable ``(collection) -> None`` invoked + after ``prepare`` completes (docs inserted, indexes created) + but before the test command executes. Use this for stateful + setup like enabling a write block. + partial_success: If True, success assertions use partial matching + (only checks that expected keys are present in the result). + Useful for commands that return extra metadata fields. + """ + + use_admin: bool = True + pre_command: Callable[[Collection], Any] | None = None + partial_success: bool = False + + def run_pre_command(self, collection: Collection) -> None: + """Execute the pre_command callable if defined.""" + if self.pre_command is not None: + self.pre_command(collection) + + def build_expected(self, ctx: CommandContext) -> dict[str, Any] | list[dict[str, Any]] | None: + """Resolve expected from a callable or plain value.""" + if self.expected is None or isinstance(self.expected, (dict, list)): + return self.expected + return self.expected(ctx) diff --git a/documentdb_tests/framework/error_codes.py b/documentdb_tests/framework/error_codes.py index 3424676d0..af7608d53 100644 --- a/documentdb_tests/framework/error_codes.py +++ b/documentdb_tests/framework/error_codes.py @@ -70,6 +70,7 @@ API_STRICT_ERROR = 323 CANNOT_CONVERT_INDEX_TO_UNIQUE_ERROR = 359 COLLECTION_UUID_MISMATCH_ERROR = 361 +USER_WRITES_BLOCKED_ERROR = 371 QUERYSETTINGS_QUERY_REJECTED_ERROR = 411 EXPRESSION_NOT_OBJECT_ERROR = 10065 BSON_OBJECT_TOO_LARGE_ERROR = 10334 From 28693cdb457df5c34b35e9e98e1fbfb42fac3efe Mon Sep 17 00:00:00 2001 From: Daniel Frankcom Date: Mon, 6 Jul 2026 19:13:44 -0700 Subject: [PATCH 10/35] Add abs expression tests (#659) Signed-off-by: Daniel Frankcom Signed-off-by: RyanGarfinkel <113050972+RyanGarfinkel@users.noreply.github.com> --- .../arithmetic/abs/test_abs_boundaries.py | 295 ++++++++++++++++++ .../arithmetic/abs/test_abs_errors.py | 92 ++++++ .../arithmetic/abs/test_abs_input_forms.py | 98 ++++++ .../arithmetic/abs/test_abs_magnitude.py | 140 +++++++++ .../arithmetic/abs/test_abs_non_finite.py | 84 +++++ .../arithmetic/abs/test_abs_null.py | 45 +++ .../arithmetic/abs/test_abs_return_type.py | 86 +++++ 7 files changed, 840 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/abs/test_abs_boundaries.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/abs/test_abs_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/abs/test_abs_input_forms.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/abs/test_abs_magnitude.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/abs/test_abs_non_finite.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/abs/test_abs_null.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/abs/test_abs_return_type.py diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/abs/test_abs_boundaries.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/abs/test_abs_boundaries.py new file mode 100644 index 000000000..4975edaf4 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/abs/test_abs_boundaries.py @@ -0,0 +1,295 @@ +"""Tests for $abs at representable-range boundaries, including overflow and underflow.""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_JUST_ABOVE_HALF, + DECIMAL128_JUST_BELOW_HALF, + DECIMAL128_LARGE_EXPONENT, + DECIMAL128_MANY_TRAILING_ZEROS, + DECIMAL128_MAX, + DECIMAL128_MIN, + DECIMAL128_SMALL_EXPONENT, + DECIMAL128_TRAILING_ZERO, + DOUBLE_JUST_ABOVE_HALF, + DOUBLE_JUST_BELOW_HALF, + DOUBLE_MAX_SAFE_INTEGER, + DOUBLE_MIN_NEGATIVE_SUBNORMAL, + DOUBLE_MIN_SUBNORMAL, + DOUBLE_NEAR_MAX, + DOUBLE_NEAR_MIN, + DOUBLE_PRECISION_LOSS, + INT32_MAX, + INT32_MAX_MINUS_1, + INT32_MIN, + INT32_MIN_PLUS_1, + INT32_OVERFLOW, + INT32_UNDERFLOW, + INT64_MAX, + INT64_MAX_MINUS_1, + INT64_MIN_PLUS_1, +) + +# Property [Int32 Boundaries]: $abs at int32 limits returns the magnitude, promoting to long when +# the result overflows int32. +ABS_INT32_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int32_max", + doc={"value": INT32_MAX}, + expression={"$abs": ["$value"]}, + expected=INT32_MAX, + msg="$abs should return INT32_MAX for INT32_MAX", + ), + ExpressionTestCase( + "int32_max_minus_1", + doc={"value": INT32_MAX_MINUS_1}, + expression={"$abs": ["$value"]}, + expected=INT32_MAX_MINUS_1, + msg="$abs should return INT32_MAX-1 for INT32_MAX-1", + ), + ExpressionTestCase( + "int32_min_plus_1", + doc={"value": INT32_MIN_PLUS_1}, + expression={"$abs": ["$value"]}, + expected=INT32_MAX, + msg="$abs should return INT32_MAX for INT32_MIN+1", + ), + ExpressionTestCase( + "int32_min", + doc={"value": INT32_MIN}, + expression={"$abs": ["$value"]}, + expected=Int64(INT32_OVERFLOW), + msg="$abs should promote to int64 for INT32_MIN", + ), + ExpressionTestCase( + "int32_overflow", + doc={"value": INT32_OVERFLOW}, + expression={"$abs": ["$value"]}, + expected=Int64(INT32_OVERFLOW), + msg="$abs should return the same long value for INT32_OVERFLOW", + ), + ExpressionTestCase( + "int32_underflow", + doc={"value": INT32_UNDERFLOW}, + expression={"$abs": ["$value"]}, + expected=Int64(-INT32_UNDERFLOW), + msg="$abs should return the negated long value for INT32_UNDERFLOW", + ), +] + +# Property [Int64 Boundaries]: $abs at int64 limits returns the magnitude as a long. +ABS_INT64_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int64_max", + doc={"value": INT64_MAX}, + expression={"$abs": ["$value"]}, + expected=INT64_MAX, + msg="$abs should return INT64_MAX for INT64_MAX", + ), + ExpressionTestCase( + "int64_max_minus_1", + doc={"value": INT64_MAX_MINUS_1}, + expression={"$abs": ["$value"]}, + expected=INT64_MAX_MINUS_1, + msg="$abs should return INT64_MAX-1 for INT64_MAX-1", + ), + ExpressionTestCase( + "int64_min_plus_1", + doc={"value": INT64_MIN_PLUS_1}, + expression={"$abs": ["$value"]}, + expected=INT64_MAX, + msg="$abs should return INT64_MAX for INT64_MIN+1", + ), +] + +# Property [Double Boundaries]: $abs preserves double magnitude across the representable range, +# for both positive inputs (a no-op) and negative inputs (an actual sign flip). +ABS_DOUBLE_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "double_min_subnormal", + doc={"value": DOUBLE_MIN_SUBNORMAL}, + expression={"$abs": ["$value"]}, + expected=DOUBLE_MIN_SUBNORMAL, + msg="$abs should return the same value for the minimum subnormal double", + ), + ExpressionTestCase( + "double_min_negative_subnormal", + doc={"value": DOUBLE_MIN_NEGATIVE_SUBNORMAL}, + expression={"$abs": ["$value"]}, + expected=DOUBLE_MIN_SUBNORMAL, + msg="$abs should return the positive subnormal for the minimum negative subnormal double", + ), + ExpressionTestCase( + "double_near_min", + doc={"value": DOUBLE_NEAR_MIN}, + expression={"$abs": ["$value"]}, + expected=DOUBLE_NEAR_MIN, + msg="$abs should return the same value for a near-min double", + ), + ExpressionTestCase( + "double_near_max", + doc={"value": DOUBLE_NEAR_MAX}, + expression={"$abs": ["$value"]}, + expected=DOUBLE_NEAR_MAX, + msg="$abs should return the same value for a near-max double", + ), + ExpressionTestCase( + "double_max_safe_integer", + doc={"value": float(DOUBLE_MAX_SAFE_INTEGER)}, + expression={"$abs": ["$value"]}, + expected=float(DOUBLE_MAX_SAFE_INTEGER), + msg="$abs should return the same value for the max safe integer double", + ), + ExpressionTestCase( + "double_precision_loss", + doc={"value": float(DOUBLE_PRECISION_LOSS)}, + expression={"$abs": ["$value"]}, + expected=float(DOUBLE_PRECISION_LOSS), + msg="$abs should return the same value for a precision loss double", + ), + ExpressionTestCase( + "double_just_below_half", + doc={"value": DOUBLE_JUST_BELOW_HALF}, + expression={"$abs": ["$value"]}, + expected=DOUBLE_JUST_BELOW_HALF, + msg="$abs should return the same value for a double just below half", + ), + ExpressionTestCase( + "negative_double_just_below_half", + doc={"value": -DOUBLE_JUST_BELOW_HALF}, + expression={"$abs": ["$value"]}, + expected=DOUBLE_JUST_BELOW_HALF, + msg="$abs should return the positive value for a negative double just below half", + ), + ExpressionTestCase( + "double_just_above_half", + doc={"value": DOUBLE_JUST_ABOVE_HALF}, + expression={"$abs": ["$value"]}, + expected=DOUBLE_JUST_ABOVE_HALF, + msg="$abs should return the same value for a double just above half", + ), + ExpressionTestCase( + "negative_double_just_above_half", + doc={"value": -DOUBLE_JUST_ABOVE_HALF}, + expression={"$abs": ["$value"]}, + expected=DOUBLE_JUST_ABOVE_HALF, + msg="$abs should return the positive value for a negative double just above half", + ), +] + +# Property [Decimal128 Boundaries]: $abs preserves decimal128 magnitude and precision, including +# trailing zeros, for both positive inputs (a no-op) and negative inputs (an actual sign flip). +ABS_DECIMAL_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "decimal128_max", + doc={"value": DECIMAL128_MAX}, + expression={"$abs": ["$value"]}, + expected=DECIMAL128_MAX, + msg="$abs should return the same value for decimal128 max", + ), + ExpressionTestCase( + "decimal128_min", + doc={"value": DECIMAL128_MIN}, + expression={"$abs": ["$value"]}, + expected=DECIMAL128_MAX, + msg="$abs should return decimal128 max for decimal128 min", + ), + ExpressionTestCase( + "decimal128_large_exponent", + doc={"value": DECIMAL128_LARGE_EXPONENT}, + expression={"$abs": ["$value"]}, + expected=DECIMAL128_LARGE_EXPONENT, + msg="$abs should return the same value for a decimal128 with a large exponent", + ), + ExpressionTestCase( + "decimal128_small_exponent", + doc={"value": DECIMAL128_SMALL_EXPONENT}, + expression={"$abs": ["$value"]}, + expected=DECIMAL128_SMALL_EXPONENT, + msg="$abs should return the same value for a decimal128 with a small exponent", + ), + ExpressionTestCase( + "decimal128_trailing_zero", + doc={"value": DECIMAL128_TRAILING_ZERO}, + expression={"$abs": ["$value"]}, + expected=DECIMAL128_TRAILING_ZERO, + msg="$abs should preserve a decimal128 trailing zero", + ), + ExpressionTestCase( + "negative_decimal128_trailing_zero", + doc={"value": Decimal128("-1.0")}, + expression={"$abs": ["$value"]}, + expected=DECIMAL128_TRAILING_ZERO, + msg="$abs should preserve a decimal128 trailing zero through negation", + ), + ExpressionTestCase( + "decimal128_many_trailing_zeros", + doc={"value": DECIMAL128_MANY_TRAILING_ZEROS}, + expression={"$abs": ["$value"]}, + expected=DECIMAL128_MANY_TRAILING_ZEROS, + msg="$abs should preserve decimal128 many trailing zeros", + ), + ExpressionTestCase( + "negative_decimal128_many_trailing_zeros", + doc={"value": Decimal128("-1.00000000000000000000000000000000")}, + expression={"$abs": ["$value"]}, + expected=DECIMAL128_MANY_TRAILING_ZEROS, + msg="$abs should preserve decimal128 many trailing zeros through negation", + ), + ExpressionTestCase( + "decimal128_just_below_half", + doc={"value": DECIMAL128_JUST_BELOW_HALF}, + expression={"$abs": ["$value"]}, + expected=DECIMAL128_JUST_BELOW_HALF, + msg="$abs should return the same value for a decimal128 just below half", + ), + ExpressionTestCase( + "negative_decimal128_just_below_half", + doc={"value": Decimal128("-0.4999999999999999999999999999999999")}, + expression={"$abs": ["$value"]}, + expected=DECIMAL128_JUST_BELOW_HALF, + msg="$abs should return the positive value for a negative decimal128 just below half", + ), + ExpressionTestCase( + "decimal128_just_above_half", + doc={"value": DECIMAL128_JUST_ABOVE_HALF}, + expression={"$abs": ["$value"]}, + expected=DECIMAL128_JUST_ABOVE_HALF, + msg="$abs should return the same value for a decimal128 just above half", + ), + ExpressionTestCase( + "negative_decimal128_just_above_half", + doc={"value": Decimal128("-0.5000000000000000000000000000000001")}, + expression={"$abs": ["$value"]}, + expected=DECIMAL128_JUST_ABOVE_HALF, + msg="$abs should return the positive value for a negative decimal128 just above half", + ), +] + +ABS_BOUNDARY_ALL_TESTS = ( + ABS_INT32_BOUNDARY_TESTS + + ABS_INT64_BOUNDARY_TESTS + + ABS_DOUBLE_BOUNDARY_TESTS + + ABS_DECIMAL_BOUNDARY_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(ABS_BOUNDARY_ALL_TESTS)) +def test_abs_boundaries(collection, test_case: ExpressionTestCase): + """Test $abs representable-range boundary cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/abs/test_abs_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/abs/test_abs_errors.py new file mode 100644 index 000000000..68a120c4b --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/abs/test_abs_errors.py @@ -0,0 +1,92 @@ +"""Tests for $abs type, arity, and overflow errors.""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + ABS_OVERFLOW_ERROR, + EXPRESSION_TYPE_MISMATCH_ERROR, + NON_NUMERIC_TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + INT64_MIN, +) + +# Property [Type Strictness]: $abs rejects non-numeric input types. +ABS_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"type_{tid}", + doc={"value": val}, + expression={"$abs": ["$value"]}, + error_code=NON_NUMERIC_TYPE_MISMATCH_ERROR, + msg=f"$abs should reject a {tid} input", + ) + for tid, val in [ + ("string", "abc"), + ("bool", True), + ("array", [1, 2]), + ("object", {"a": 1}), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("regex", Regex("abc")), + ("binary", Binary(b"data")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("timestamp", Timestamp(1, 1)), + ("code", Code("function(){}")), + ] +] + +# Property [Arity]: $abs requires exactly one argument. +ABS_ARITY_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "arity_zero", + doc={}, + expression={"$abs": []}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="$abs should reject zero arguments", + ), + ExpressionTestCase( + "arity_two", + doc={}, + expression={"$abs": [1, 2]}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="$abs should reject two arguments", + ), +] + +# Property [Overflow]: $abs of the minimum int64 errors because the positive result is not +# representable as a long. +ABS_OVERFLOW_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int64_min_overflow", + doc={"value": INT64_MIN}, + expression={"$abs": ["$value"]}, + error_code=ABS_OVERFLOW_ERROR, + msg="$abs should error on overflow for INT64_MIN", + ), +] + +ABS_ERROR_ALL_TESTS = ABS_TYPE_ERROR_TESTS + ABS_ARITY_ERROR_TESTS + ABS_OVERFLOW_TESTS + + +@pytest.mark.parametrize("test_case", pytest_params(ABS_ERROR_ALL_TESTS)) +def test_abs_errors(collection, test_case: ExpressionTestCase): + """Test $abs type, arity, and overflow error cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/abs/test_abs_input_forms.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/abs/test_abs_input_forms.py new file mode 100644 index 000000000..a6362638e --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/abs/test_abs_input_forms.py @@ -0,0 +1,98 @@ +"""Tests for $abs argument forms, literals, field paths, and nested expression inputs.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import NON_NUMERIC_TYPE_MISMATCH_ERROR +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Argument Form]: $abs accepts its single argument bare or wrapped in a one-element +# array. +ABS_ARGUMENT_FORM_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "form_array", + doc={"value": -5}, + expression={"$abs": ["$value"]}, + expected=5, + msg="$abs should accept its argument wrapped in a one-element array", + ), + ExpressionTestCase( + "form_bare", + doc={"value": -5}, + expression={"$abs": "$value"}, + expected=5, + msg="$abs should accept its argument without an array wrapper", + ), +] + +# Property [Literal Input]: $abs evaluates an inline literal argument, not only document fields. +ABS_LITERAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "literal_input", + doc={}, + expression={"$abs": [-5]}, + expected=5, + msg="$abs should return the absolute value of an inline literal argument", + ), +] + +# Property [Expression Input]: $abs evaluates a nested expression argument before taking the +# absolute value. +ABS_EXPRESSION_INPUT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_abs", + doc={"value": -4}, + expression={"$abs": {"$abs": "$value"}}, + expected=4, + msg="$abs should evaluate a nested $abs expression argument", + ), +] + +# Property [Field Path Input]: $abs resolves a field path argument. A dotted path into a nested +# object yields the referenced value; a path over an array of objects resolves to an array, which +# $abs rejects as a non-numeric type. +ABS_FIELD_PATH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_field_path", + doc={"a": {"b": -5}}, + expression={"$abs": "$a.b"}, + expected=5, + msg="$abs should resolve a dotted field path into a nested object", + ), + ExpressionTestCase( + "composite_array_field_path", + doc={"a": [{"b": -1}, {"b": -2}]}, + expression={"$abs": "$a.b"}, + error_code=NON_NUMERIC_TYPE_MISMATCH_ERROR, + msg="$abs should reject a field path that resolves to an array from an array of objects", + ), + ExpressionTestCase( + "array_index_field_path", + doc={"a": [-5, -6]}, + expression={"$abs": "$a.0"}, + error_code=NON_NUMERIC_TYPE_MISMATCH_ERROR, + msg="$abs should reject a numeric path component over an array, which resolves non-numeric", + ), +] + +ABS_INPUT_FORM_TESTS = ( + ABS_ARGUMENT_FORM_TESTS + ABS_LITERAL_TESTS + ABS_EXPRESSION_INPUT_TESTS + ABS_FIELD_PATH_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(ABS_INPUT_FORM_TESTS)) +def test_abs_input_forms(collection, test_case: ExpressionTestCase): + """Test $abs argument form, literal, field path, and nested expression input cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/abs/test_abs_magnitude.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/abs/test_abs_magnitude.py new file mode 100644 index 000000000..ff795b40a --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/abs/test_abs_magnitude.py @@ -0,0 +1,140 @@ +"""Tests for $abs magnitude across numeric types and signed-zero handling.""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ZERO, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_ZERO, + INT64_ZERO, +) + +# Property [Magnitude]: $abs returns the absolute value of a number, preserving its numeric type. +ABS_MAGNITUDE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "positive_int32", + doc={"value": 1}, + expression={"$abs": ["$value"]}, + expected=1, + msg="$abs should return the same value for a positive int32", + ), + ExpressionTestCase( + "negative_int32", + doc={"value": -1}, + expression={"$abs": ["$value"]}, + expected=1, + msg="$abs should return the positive value for a negative int32", + ), + ExpressionTestCase( + "positive_int64", + doc={"value": Int64(1)}, + expression={"$abs": ["$value"]}, + expected=Int64(1), + msg="$abs should return the same value for a positive int64", + ), + ExpressionTestCase( + "negative_int64", + doc={"value": Int64(-1)}, + expression={"$abs": ["$value"]}, + expected=Int64(1), + msg="$abs should return the positive value for a negative int64", + ), + ExpressionTestCase( + "positive_double", + doc={"value": 1.5}, + expression={"$abs": ["$value"]}, + expected=1.5, + msg="$abs should return the same value for a positive double", + ), + ExpressionTestCase( + "negative_double", + doc={"value": -1.5}, + expression={"$abs": ["$value"]}, + expected=1.5, + msg="$abs should return the positive value for a negative double", + ), + ExpressionTestCase( + "positive_decimal", + doc={"value": Decimal128("1")}, + expression={"$abs": ["$value"]}, + expected=Decimal128("1"), + msg="$abs should return the same value for a positive decimal128", + ), + ExpressionTestCase( + "negative_decimal", + doc={"value": Decimal128("-1")}, + expression={"$abs": ["$value"]}, + expected=Decimal128("1"), + msg="$abs should return the positive value for a negative decimal128", + ), +] + +# Property [Zero]: $abs of positive or negative zero returns positive zero for each numeric type. +ABS_ZERO_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "zero_int32", + doc={"value": 0}, + expression={"$abs": ["$value"]}, + expected=0, + msg="$abs should return zero for int32 zero", + ), + ExpressionTestCase( + "zero_int64", + doc={"value": INT64_ZERO}, + expression={"$abs": ["$value"]}, + expected=INT64_ZERO, + msg="$abs should return zero for int64 zero", + ), + ExpressionTestCase( + "zero_double", + doc={"value": DOUBLE_ZERO}, + expression={"$abs": ["$value"]}, + expected=DOUBLE_ZERO, + msg="$abs should return zero for double zero", + ), + ExpressionTestCase( + "negative_zero_double", + doc={"value": DOUBLE_NEGATIVE_ZERO}, + expression={"$abs": ["$value"]}, + expected=DOUBLE_ZERO, + msg="$abs should return positive zero for negative zero double", + ), + ExpressionTestCase( + "zero_decimal", + doc={"value": DECIMAL128_ZERO}, + expression={"$abs": ["$value"]}, + expected=DECIMAL128_ZERO, + msg="$abs should return zero for decimal128 zero", + ), + ExpressionTestCase( + "negative_zero_decimal", + doc={"value": DECIMAL128_NEGATIVE_ZERO}, + expression={"$abs": ["$value"]}, + expected=DECIMAL128_ZERO, + msg="$abs should return positive zero for negative zero decimal128", + ), +] + +ABS_MAGNITUDE_ALL_TESTS = ABS_MAGNITUDE_TESTS + ABS_ZERO_TESTS + + +@pytest.mark.parametrize("test_case", pytest_params(ABS_MAGNITUDE_ALL_TESTS)) +def test_abs_magnitude(collection, test_case: ExpressionTestCase): + """Test $abs magnitude and signed-zero cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/abs/test_abs_non_finite.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/abs/test_abs_non_finite.py new file mode 100644 index 000000000..0457880a4 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/abs/test_abs_non_finite.py @@ -0,0 +1,84 @@ +"""Tests for $abs with infinity and NaN inputs.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, +) + +# Property [Infinity]: $abs of positive or negative infinity returns positive infinity. +ABS_INFINITY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "float_infinity", + doc={"value": FLOAT_INFINITY}, + expression={"$abs": ["$value"]}, + expected=FLOAT_INFINITY, + msg="$abs should return float infinity for float infinity", + ), + ExpressionTestCase( + "float_negative_infinity", + doc={"value": FLOAT_NEGATIVE_INFINITY}, + expression={"$abs": ["$value"]}, + expected=FLOAT_INFINITY, + msg="$abs should return positive infinity for float negative infinity", + ), + ExpressionTestCase( + "decimal128_infinity", + doc={"value": DECIMAL128_INFINITY}, + expression={"$abs": ["$value"]}, + expected=DECIMAL128_INFINITY, + msg="$abs should return decimal128 infinity for decimal128 infinity", + ), + ExpressionTestCase( + "decimal128_negative_infinity", + doc={"value": DECIMAL128_NEGATIVE_INFINITY}, + expression={"$abs": ["$value"]}, + expected=DECIMAL128_INFINITY, + msg="$abs should return decimal128 positive infinity for decimal128 negative infinity", + ), +] + +# Property [NaN]: $abs of NaN returns NaN of the same type. +ABS_NAN_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "float_nan", + doc={"value": FLOAT_NAN}, + expression={"$abs": ["$value"]}, + expected=pytest.approx(FLOAT_NAN, nan_ok=True), + msg="$abs should return NaN for float NaN", + ), + ExpressionTestCase( + "decimal128_nan", + doc={"value": DECIMAL128_NAN}, + expression={"$abs": ["$value"]}, + expected=DECIMAL128_NAN, + msg="$abs should return NaN for decimal128 NaN", + ), +] + +ABS_NON_FINITE_TESTS = ABS_INFINITY_TESTS + ABS_NAN_TESTS + + +@pytest.mark.parametrize("test_case", pytest_params(ABS_NON_FINITE_TESTS)) +def test_abs_non_finite(collection, test_case: ExpressionTestCase): + """Test $abs infinity and NaN cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/abs/test_abs_null.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/abs/test_abs_null.py new file mode 100644 index 000000000..f6ba0500d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/abs/test_abs_null.py @@ -0,0 +1,45 @@ +"""Tests for $abs null and missing-field propagation.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + MISSING, +) + +# Property [Null Propagation]: $abs of null or a missing field returns null. +ABS_NULL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_value", + doc={"value": None}, + expression={"$abs": ["$value"]}, + expected=None, + msg="$abs should return null for null input", + ), + ExpressionTestCase( + "missing_field", + doc={}, + expression={"$abs": [MISSING]}, + expected=None, + msg="$abs should return null for a missing field", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(ABS_NULL_TESTS)) +def test_abs_null(collection, test_case: ExpressionTestCase): + """Test $abs null and missing field propagation cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/abs/test_abs_return_type.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/abs/test_abs_return_type.py new file mode 100644 index 000000000..7768e1837 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/abs/test_abs_return_type.py @@ -0,0 +1,86 @@ +"""Tests for $abs return type preservation.""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Return Type]: $abs preserves the numeric type of its input, for both negative inputs +# (an actual sign flip) and positive inputs (a no-op). +ABS_RETURN_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "return_type_positive_int32", + doc={"value": 5}, + expression={"$type": {"$abs": "$value"}}, + expected="int", + msg="$abs should preserve int32 type for a positive int32", + ), + ExpressionTestCase( + "return_type_negative_int32", + doc={"value": -5}, + expression={"$type": {"$abs": "$value"}}, + expected="int", + msg="$abs should preserve int32 type for a negative int32", + ), + ExpressionTestCase( + "return_type_positive_int64", + doc={"value": Int64(5)}, + expression={"$type": {"$abs": "$value"}}, + expected="long", + msg="$abs should preserve int64 type for a positive int64", + ), + ExpressionTestCase( + "return_type_negative_int64", + doc={"value": Int64(-5)}, + expression={"$type": {"$abs": "$value"}}, + expected="long", + msg="$abs should preserve int64 type for a negative int64", + ), + ExpressionTestCase( + "return_type_positive_double", + doc={"value": 5.0}, + expression={"$type": {"$abs": "$value"}}, + expected="double", + msg="$abs should preserve double type for a positive double", + ), + ExpressionTestCase( + "return_type_negative_double", + doc={"value": -5.0}, + expression={"$type": {"$abs": "$value"}}, + expected="double", + msg="$abs should preserve double type for a negative double", + ), + ExpressionTestCase( + "return_type_positive_decimal", + doc={"value": Decimal128("5")}, + expression={"$type": {"$abs": "$value"}}, + expected="decimal", + msg="$abs should preserve decimal128 type for a positive decimal128", + ), + ExpressionTestCase( + "return_type_negative_decimal", + doc={"value": Decimal128("-5")}, + expression={"$type": {"$abs": "$value"}}, + expected="decimal", + msg="$abs should preserve decimal128 type for a negative decimal128", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(ABS_RETURN_TYPE_TESTS)) +def test_abs_return_type(collection, test_case: ExpressionTestCase): + """Test $abs return type preservation cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) From 8a9d004516335837d2c28b464021db7a60ed0fd2 Mon Sep 17 00:00:00 2001 From: "Paterson.How" <71473631+PatersonProjects@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:03:05 -0700 Subject: [PATCH 11/35] Added $add and $ceil tests (#660) Signed-off-by: PatersonProjects Signed-off-by: RyanGarfinkel <113050972+RyanGarfinkel@users.noreply.github.com> --- .../expressions/arithmetic/add/__init__.py | 0 .../arithmetic/add/test_add_date.py | 166 +++++++++++ .../arithmetic/add/test_add_errors.py | 216 ++++++++++++++ .../arithmetic/add/test_add_input_forms.py | 49 ++++ .../arithmetic/add/test_add_non_finite.py | 156 +++++++++++ .../arithmetic/add/test_add_null.py | 88 ++++++ .../arithmetic/add/test_add_numeric.py | 263 ++++++++++++++++++ .../arithmetic/add/test_add_overflow.py | 97 +++++++ .../arithmetic/add/test_add_precision.py | 105 +++++++ .../arithmetic/add/test_add_return_type.py | 116 ++++++++ .../expressions/arithmetic/ceil/__init__.py | 0 .../arithmetic/ceil/test_ceil_boundaries.py | 237 ++++++++++++++++ .../arithmetic/ceil/test_ceil_errors.py | 89 ++++++ .../arithmetic/ceil/test_ceil_input_forms.py | 76 +++++ .../arithmetic/ceil/test_ceil_non_finite.py | 87 ++++++ .../arithmetic/ceil/test_ceil_null.py | 45 +++ .../arithmetic/ceil/test_ceil_numeric.py | 162 +++++++++++ .../arithmetic/ceil/test_ceil_return_type.py | 57 ++++ 18 files changed, 2009 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_date.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_input_forms.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_non_finite.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_null.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_numeric.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_overflow.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_precision.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_return_type.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ceil/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ceil/test_ceil_boundaries.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ceil/test_ceil_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ceil/test_ceil_input_forms.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ceil/test_ceil_non_finite.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ceil/test_ceil_null.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ceil/test_ceil_numeric.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ceil/test_ceil_return_type.py diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_date.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_date.py new file mode 100644 index 000000000..eff8f4af1 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_date.py @@ -0,0 +1,166 @@ +"""Tests for $add date arithmetic including numeric offsets, rounding boundaries, operand +position, and sign handling. +""" + +from datetime import datetime, timedelta, timezone + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Date Arithmetic]: $add accepts exactly one date operand and one or more numeric +# operands (in milliseconds). The date may appear in any position. +ADD_DATE_NUMERIC_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "date_int32", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": 86400000}, + expression={"$add": ["$a", "$b"]}, + expected=datetime(2026, 1, 2, tzinfo=timezone.utc), + msg="$add should add int32 milliseconds to a date", + ), + ExpressionTestCase( + "date_int64", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": Int64(86400000)}, + expression={"$add": ["$a", "$b"]}, + expected=datetime(2026, 1, 2, tzinfo=timezone.utc), + msg="$add should add int64 milliseconds to a date", + ), + ExpressionTestCase( + "date_decimal", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": Decimal128("1.5")}, + expression={"$add": ["$a", "$b"]}, + expected=datetime(2026, 1, 1, 0, 0, 0, 2000, tzinfo=timezone.utc), + msg="$add should round a decimal128 fractional millisecond value when adding to a date", + ), + ExpressionTestCase( + "date_double_round_up", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": 2.5}, + expression={"$add": ["$a", "$b"]}, + expected=datetime(2026, 1, 1, 0, 0, 0, 3000, tzinfo=timezone.utc), + msg="$add should round up a double fractional millisecond value (.5) when adding to a date", + ), + ExpressionTestCase( + "date_double_truncates", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": 4.4}, + expression={"$add": ["$a", "$b"]}, + expected=datetime(2026, 1, 1, 0, 0, 0, 4000, tzinfo=timezone.utc), + msg="$add should truncate a double fractional millisecond value (<.5) when adding to a date", # noqa: E501 + ), +] + +# Property [Date Rounding Boundaries]: $add rounds fractional millisecond offsets using +# round-half-away-from-zero. Values with |frac| < 0.5 truncate toward zero; values with +# |frac| >= 0.5 round away from zero. +ADD_DATE_ROUNDING_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "date_double_0_1", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": 0.1}, + expression={"$add": ["$a", "$b"]}, + expected=datetime(2026, 1, 1, tzinfo=timezone.utc), + msg="$add should truncate 0.1ms and leave the date unchanged", + ), + ExpressionTestCase( + "date_double_0_49", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": 0.49}, + expression={"$add": ["$a", "$b"]}, + expected=datetime(2026, 1, 1, tzinfo=timezone.utc), + msg="$add should truncate 0.49ms and leave the date unchanged", + ), + ExpressionTestCase( + "date_double_0_51", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": 0.51}, + expression={"$add": ["$a", "$b"]}, + expected=datetime(2026, 1, 1, 0, 0, 0, 1000, tzinfo=timezone.utc), + msg="$add should round up 0.51ms to 1ms when adding to a date", + ), + ExpressionTestCase( + "date_double_0_6", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": 0.6}, + expression={"$add": ["$a", "$b"]}, + expected=datetime(2026, 1, 1, 0, 0, 0, 1000, tzinfo=timezone.utc), + msg="$add should round up 0.6ms to 1ms when adding to a date", + ), + ExpressionTestCase( + "date_double_neg_0_5", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": -0.5}, + expression={"$add": ["$a", "$b"]}, + expected=datetime(2026, 1, 1, tzinfo=timezone.utc) - timedelta(milliseconds=1), + msg="$add should round -0.5ms away from zero to -1ms when adding to a date", + ), + ExpressionTestCase( + "date_double_neg_0_51", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": -0.51}, + expression={"$add": ["$a", "$b"]}, + expected=datetime(2026, 1, 1, tzinfo=timezone.utc) - timedelta(milliseconds=1), + msg="$add should round -0.51ms away from zero to -1ms when adding to a date", + ), +] + +# Property [Date Operand Position]: the date operand may appear in any position among the +# operands; only one date is permitted. +ADD_DATE_POSITION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "number_then_date", + doc={"a": 86400000, "b": datetime(2026, 1, 1, tzinfo=timezone.utc)}, + expression={"$add": ["$a", "$b"]}, + expected=datetime(2026, 1, 2, tzinfo=timezone.utc), + msg="$add should add a date when the numeric operand appears before the date", + ), + ExpressionTestCase( + "date_in_middle", + doc={"a": 1, "b": datetime(2026, 1, 1, tzinfo=timezone.utc), "c": 1000}, + expression={"$add": ["$a", "$b", "$c"]}, + expected=datetime(2026, 1, 1, 0, 0, 1, 1000, tzinfo=timezone.utc), + msg="$add should add a date when it appears in the middle of the operand list", + ), +] + +# Property [Date Sign Handling]: adding zero or a negative number of milliseconds to a date +# returns the date unchanged or subtracted. +ADD_DATE_SIGN_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "date_negative", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": -86400000}, + expression={"$add": ["$a", "$b"]}, + expected=datetime(2025, 12, 31, tzinfo=timezone.utc), + msg="$add should subtract milliseconds from a date when adding a negative number", + ), + ExpressionTestCase( + "date_zero", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": 0}, + expression={"$add": ["$a", "$b"]}, + expected=datetime(2026, 1, 1, tzinfo=timezone.utc), + msg="$add should return the same date when adding zero milliseconds", + ), + ExpressionTestCase( + "date_negative_zero", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": -0.0}, + expression={"$add": ["$a", "$b"]}, + expected=datetime(2026, 1, 1, tzinfo=timezone.utc), + msg="$add should return the same date when adding negative zero", + ), +] + +ADD_DATE_ALL_TESTS = ( + ADD_DATE_NUMERIC_TESTS + ADD_DATE_POSITION_TESTS + ADD_DATE_SIGN_TESTS + ADD_DATE_ROUNDING_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(ADD_DATE_ALL_TESTS)) +def test_add_date(collection, test_case: ExpressionTestCase): + """Test $add date arithmetic: numeric types, operand position, and sign handling.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_errors.py new file mode 100644 index 000000000..8e319d956 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_errors.py @@ -0,0 +1,216 @@ +"""Tests for $add error cases including invalid operand types, multiple dates, and date overflow.""" + +from datetime import datetime, timezone + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.bson_type_validator import ( + BsonType, + BsonTypeTestCase, + generate_bson_rejection_test_cases, +) +from documentdb_tests.framework.error_codes import ( + MORE_THAN_ONE_DATE_ERROR, + OVERFLOW_ERROR, + TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + FLOAT_INFINITY, + FLOAT_NAN, + INT64_MAX, +) + +_NUMERIC_DATE_AND_NULL_TYPES = [ + BsonType.DOUBLE, + BsonType.INT, + BsonType.LONG, + BsonType.DECIMAL, + BsonType.DATE, + BsonType.NULL, +] + +# Property [Type Strictness]: $add rejects non-numeric, non-date operand types. +ADD_TYPE_SPEC = BsonTypeTestCase( + id="add_operand", + msg="$add should reject non-numeric, non-date operand", + keyword="$add", + valid_types=_NUMERIC_DATE_AND_NULL_TYPES, + default_error_code=TYPE_MISMATCH_ERROR, +) + +ADD_TYPE_REJECTION_CASES = generate_bson_rejection_test_cases([ADD_TYPE_SPEC]) +_ADD_TYPE_REJECTION_EXPR = {"$add": [1, "$b"]} + + +@pytest.mark.parametrize("bson_type,sample_value,spec", ADD_TYPE_REJECTION_CASES) +def test_add_rejects_invalid_operand_type(collection, bson_type, sample_value, spec): + """Test $add rejects non-numeric, non-date BSON types as operands.""" + result = execute_expression_with_insert( + collection, _ADD_TYPE_REJECTION_EXPR, {"b": sample_value} + ) + assertFailureCode(result, spec.expected_code(bson_type), msg=spec.msg) + + +# Property [Mixed Valid and Invalid]: $add rejects an invalid operand when it appears among +# valid numeric operands. +ADD_MIXED_VALID_INVALID_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "mixed_valid_invalid", + doc={"a": 1, "b": 2, "c": "string"}, + expression={"$add": ["$a", "$b", "$c"]}, + error_code=TYPE_MISMATCH_ERROR, + msg="$add should error when a string appears among numeric operands", + ), +] + +# Property [Single Invalid Operand]: $add rejects a single operand of an invalid type. +ADD_SINGLE_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "single_string", + doc={"a": "string"}, + expression={"$add": ["$a"]}, + error_code=TYPE_MISMATCH_ERROR, + msg="$add should reject a single string operand", + ), + ExpressionTestCase( + "single_boolean", + doc={"a": True}, + expression={"$add": ["$a"]}, + error_code=TYPE_MISMATCH_ERROR, + msg="$add should reject a single boolean operand", + ), + ExpressionTestCase( + "single_array", + doc={"a": [1, 2]}, + expression={"$add": ["$a"]}, + error_code=TYPE_MISMATCH_ERROR, + msg="$add should reject a single array operand", + ), + ExpressionTestCase( + "single_object", + doc={"a": {"x": 1}}, + expression={"$add": ["$a"]}, + error_code=TYPE_MISMATCH_ERROR, + msg="$add should reject a single object operand", + ), +] + +# Property [Multiple Dates]: $add rejects expressions with more than one date operand. +ADD_MULTIPLE_DATE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "add_two_identical_dates", + doc={ + "a": datetime(2026, 1, 1, tzinfo=timezone.utc), + "b": datetime(2026, 1, 1, tzinfo=timezone.utc), + }, + expression={"$add": ["$a", "$b"]}, + error_code=MORE_THAN_ONE_DATE_ERROR, + msg="$add should error when adding two identical date operands", + ), + ExpressionTestCase( + "two_different_dates", + doc={ + "a": datetime(2026, 1, 1, tzinfo=timezone.utc), + "b": datetime(2026, 1, 2, tzinfo=timezone.utc), + }, + expression={"$add": ["$a", "$b"]}, + error_code=MORE_THAN_ONE_DATE_ERROR, + msg="$add should error when adding two different date operands", + ), + ExpressionTestCase( + "two_dates_with_numbers", + doc={ + "a": datetime(2026, 1, 1, tzinfo=timezone.utc), + "b": datetime(2026, 1, 2, tzinfo=timezone.utc), + }, + expression={"$add": [1, 2, 3, "$a", "$b"]}, + error_code=MORE_THAN_ONE_DATE_ERROR, + msg="$add should error when two dates appear among numeric operands", + ), + ExpressionTestCase( + "dates_separated_by_number", + doc={ + "a": datetime(2026, 1, 1, tzinfo=timezone.utc), + "b": datetime(2026, 1, 2, tzinfo=timezone.utc), + }, + expression={"$add": ["$a", 1, "$b"]}, + error_code=MORE_THAN_ONE_DATE_ERROR, + msg="$add should error when two dates are separated by a numeric operand", + ), +] + +# Property [Date with Non-Finite]: $add rejects NaN and Infinity as numeric operands when a +# date is also present, since the resulting date would be non-representable. +ADD_DATE_NON_FINITE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "date_nan", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": FLOAT_NAN}, + expression={"$add": ["$a", "$b"]}, + error_code=OVERFLOW_ERROR, + msg="$add should error when adding a date and float NaN", + ), + ExpressionTestCase( + "date_infinity", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": FLOAT_INFINITY}, + expression={"$add": ["$a", "$b"]}, + error_code=OVERFLOW_ERROR, + msg="$add should error when adding a date and float infinity", + ), + ExpressionTestCase( + "date_decimal_nan", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": DECIMAL128_NAN}, + expression={"$add": ["$a", "$b"]}, + error_code=OVERFLOW_ERROR, + msg="$add should error when adding a date and decimal128 NaN", + ), + ExpressionTestCase( + "date_decimal_infinity", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": DECIMAL128_INFINITY}, + expression={"$add": ["$a", "$b"]}, + error_code=OVERFLOW_ERROR, + msg="$add should error when adding a date and decimal128 infinity", + ), +] + +# Property [Date Overflow]: $add errors when the millisecond offset would push the date result +# beyond the representable date range. +ADD_DATE_OVERFLOW_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "date_int64_max", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": INT64_MAX}, + expression={"$add": ["$a", "$b"]}, + error_code=OVERFLOW_ERROR, + msg="$add should error when adding INT64_MAX milliseconds to a date overflows the date range", # noqa: E501 + ), +] + +ADD_REMAINING_ERROR_TESTS = ( + ADD_MIXED_VALID_INVALID_TESTS + + ADD_SINGLE_TYPE_ERROR_TESTS + + ADD_MULTIPLE_DATE_TESTS + + ADD_DATE_NON_FINITE_ERROR_TESTS + + ADD_DATE_OVERFLOW_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(ADD_REMAINING_ERROR_TESTS)) +def test_add_errors(collection, test_case: ExpressionTestCase): + """Test $add multiple-date, single-invalid, and date non-finite error cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_input_forms.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_input_forms.py new file mode 100644 index 000000000..fa2f73df4 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_input_forms.py @@ -0,0 +1,49 @@ +"""Tests for $add input forms including nested expressions and mixed literal/field operands.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Expression Input]: $add evaluates a nested expression argument before summing. +ADD_EXPRESSION_INPUT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_add", + doc={"a": 1, "b": 2}, + expression={"$add": [{"$add": ["$a", "$b"]}, 3]}, + expected=6, + msg="$add should evaluate a nested $add expression as an operand", + ), +] + +# Property [Mixed Literal and Field]: $add accepts a mix of field references and inline literals +# in the same operand list. +ADD_MIXED_INPUT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "mixed_literal_and_field", + doc={"a": 10}, + expression={"$add": ["$a", 5]}, + expected=15, + msg="$add should sum a field reference and an inline literal operand", + ), +] + +ADD_INPUT_FORM_ALL_TESTS = ADD_EXPRESSION_INPUT_TESTS + ADD_MIXED_INPUT_TESTS + + +@pytest.mark.parametrize("test_case", pytest_params(ADD_INPUT_FORM_ALL_TESTS)) +def test_add_input_forms(collection, test_case: ExpressionTestCase): + """Test $add literal, nested expression, and mixed input form cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_non_finite.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_non_finite.py new file mode 100644 index 000000000..958818e98 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_non_finite.py @@ -0,0 +1,156 @@ +"""Tests for $add infinity and NaN propagation.""" + +import math + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, +) + +# Property [Infinity]: $add propagates infinity according to IEEE 754 rules. +ADD_INFINITY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "infinity", + doc={"a": FLOAT_INFINITY, "b": 1}, + expression={"$add": ["$a", "$b"]}, + expected=FLOAT_INFINITY, + msg="$add should return infinity when adding infinity and a finite number", + ), + ExpressionTestCase( + "negative_infinity", + doc={"a": FLOAT_NEGATIVE_INFINITY, "b": 1}, + expression={"$add": ["$a", "$b"]}, + expected=FLOAT_NEGATIVE_INFINITY, + msg="$add should return negative infinity when adding negative infinity and a finite number", # noqa: E501 + ), + ExpressionTestCase( + "single_infinity", + doc={"a": FLOAT_INFINITY}, + expression={"$add": ["$a"]}, + expected=FLOAT_INFINITY, + msg="$add should return infinity for a single infinity operand", + ), + ExpressionTestCase( + "inf_plus_inf", + doc={"a": FLOAT_INFINITY, "b": FLOAT_INFINITY}, + expression={"$add": ["$a", "$b"]}, + expected=FLOAT_INFINITY, + msg="$add should return infinity when adding two positive infinities", + ), + ExpressionTestCase( + "neg_inf_plus_neg_inf", + doc={"a": FLOAT_NEGATIVE_INFINITY, "b": FLOAT_NEGATIVE_INFINITY}, + expression={"$add": ["$a", "$b"]}, + expected=FLOAT_NEGATIVE_INFINITY, + msg="$add should return negative infinity when adding two negative infinities", + ), + ExpressionTestCase( + "inf_plus_zero", + doc={"a": FLOAT_INFINITY, "b": 0}, + expression={"$add": ["$a", "$b"]}, + expected=FLOAT_INFINITY, + msg="$add should return infinity when adding infinity and zero", + ), + ExpressionTestCase( + "neg_inf_plus_zero", + doc={"a": FLOAT_NEGATIVE_INFINITY, "b": 0}, + expression={"$add": ["$a", "$b"]}, + expected=FLOAT_NEGATIVE_INFINITY, + msg="$add should return negative infinity when adding negative infinity and zero", + ), + ExpressionTestCase( + "decimal_infinity", + doc={"a": DECIMAL128_INFINITY, "b": 1}, + expression={"$add": ["$a", "$b"]}, + expected=DECIMAL128_INFINITY, + msg="$add should return decimal128 infinity when adding decimal128 infinity and a number", + ), + ExpressionTestCase( + "decimal_negative_infinity", + doc={"a": DECIMAL128_NEGATIVE_INFINITY, "b": 1}, + expression={"$add": ["$a", "$b"]}, + expected=DECIMAL128_NEGATIVE_INFINITY, + msg="$add should return decimal128 negative infinity when adding decimal128 negative infinity and a number", # noqa: E501 + ), +] + +# Property [NaN]: $add propagates NaN according to IEEE 754 rules. +ADD_NAN_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nan_add_one", + doc={"a": FLOAT_NAN, "b": 1}, + expression={"$add": ["$a", "$b"]}, + expected=pytest.approx(math.nan, nan_ok=True), + msg="$add should return NaN when adding float NaN and a finite number", + ), + ExpressionTestCase( + "inf_minus_inf", + doc={"a": FLOAT_INFINITY, "b": FLOAT_NEGATIVE_INFINITY}, + expression={"$add": ["$a", "$b"]}, + expected=pytest.approx(math.nan, nan_ok=True), + msg="$add should return NaN when adding float infinity and negative infinity", + ), + ExpressionTestCase( + "nan_plus_nan", + doc={"a": FLOAT_NAN, "b": FLOAT_NAN}, + expression={"$add": ["$a", "$b"]}, + expected=pytest.approx(math.nan, nan_ok=True), + msg="$add should return NaN when adding two float NaN values", + ), + ExpressionTestCase( + "nan_plus_inf", + doc={"a": FLOAT_NAN, "b": FLOAT_INFINITY}, + expression={"$add": ["$a", "$b"]}, + expected=pytest.approx(math.nan, nan_ok=True), + msg="$add should return NaN when adding float NaN and infinity", + ), + ExpressionTestCase( + "decimal_nan", + doc={"a": DECIMAL128_NAN, "b": 1}, + expression={"$add": ["$a", "$b"]}, + expected=DECIMAL128_NAN, + msg="$add should return decimal128 NaN when adding decimal128 NaN and a number", + ), + ExpressionTestCase( + "decimal_nan_plus_nan", + doc={"a": DECIMAL128_NAN, "b": DECIMAL128_NAN}, + expression={"$add": ["$a", "$b"]}, + expected=DECIMAL128_NAN, + msg="$add should return decimal128 NaN when adding two decimal128 NaN values", + ), + ExpressionTestCase( + "decimal_inf_minus_inf", + doc={"a": DECIMAL128_INFINITY, "b": DECIMAL128_NEGATIVE_INFINITY}, + expression={"$add": ["$a", "$b"]}, + expected=DECIMAL128_NAN, + msg="$add should return decimal128 NaN when adding decimal128 infinity and negative infinity", # noqa: E501 + ), +] + +ADD_NON_FINITE_ALL_TESTS = ADD_INFINITY_TESTS + ADD_NAN_TESTS + + +@pytest.mark.parametrize("test_case", pytest_params(ADD_NON_FINITE_ALL_TESTS)) +def test_add_non_finite(collection, test_case: ExpressionTestCase): + """Test $add infinity and NaN propagation cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_null.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_null.py new file mode 100644 index 000000000..3854d72d8 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_null.py @@ -0,0 +1,88 @@ +"""Tests for $add null and missing field propagation.""" + +from datetime import datetime, timezone + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import MISSING + +# Property [Null Propagation]: $add returns null if any operand is null or refers to a missing +# field. +ADD_NULL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "single_null", + doc={"a": None}, + expression={"$add": ["$a"]}, + expected=None, + msg="$add should return null for a single null operand", + ), + ExpressionTestCase( + "null_operand", + doc={"a": 1, "b": None}, + expression={"$add": ["$a", "$b"]}, + expected=None, + msg="$add should return null when any operand is null", + ), + ExpressionTestCase( + "missing_field", + doc={}, + expression={"$add": [1, MISSING]}, + expected=None, + msg="$add should return null when any operand is a missing field", + ), + ExpressionTestCase( + "null_with_multiple", + doc={"a": 1, "b": 2, "c": None}, + expression={"$add": ["$a", "$b", "$c"]}, + expected=None, + msg="$add should return null when null appears among multiple operands", + ), + ExpressionTestCase( + "null_in_middle", + doc={"a": 1, "b": 2, "c": 3, "e": 5}, + expression={"$add": ["$a", "$b", "$c", None, "$e"]}, + expected=None, + msg="$add should return null when null appears in the middle of operands", + ), + ExpressionTestCase( + "all_null", + doc={"a": None, "b": None}, + expression={"$add": ["$a", "$b"]}, + expected=None, + msg="$add should return null when all operands are null", + ), + ExpressionTestCase( + "all_missing", + doc={}, + expression={"$add": [MISSING, MISSING]}, + expected=None, + msg="$add should return null when all operands are missing fields", + ), + ExpressionTestCase( + "date_and_null", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": None}, + expression={"$add": ["$a", "$b"]}, + expected=None, + msg="$add should return null when a date is paired with a null operand", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(ADD_NULL_TESTS)) +def test_add_null(collection, test_case: ExpressionTestCase): + """Test $add null and missing field propagation cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_numeric.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_numeric.py new file mode 100644 index 000000000..971b01581 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_numeric.py @@ -0,0 +1,263 @@ +"""Tests for $add numeric operations including same-type and mixed-type addition, multiple +operands, empty/single operands, and sign handling. +""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Same-Type Addition]: $add of two values of the same numeric type returns a value of +# that type. +ADD_SAME_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "same_type_int32", + doc={"a": 1, "b": 2}, + expression={"$add": ["$a", "$b"]}, + expected=3, + msg="$add should add two int32 values", + ), + ExpressionTestCase( + "same_type_int64", + doc={"a": Int64(10), "b": Int64(20)}, + expression={"$add": ["$a", "$b"]}, + expected=Int64(30), + msg="$add should add two int64 values", + ), + ExpressionTestCase( + "same_type_double", + doc={"a": 1.5, "b": 2.5}, + expression={"$add": ["$a", "$b"]}, + expected=4.0, + msg="$add should add two double values", + ), + ExpressionTestCase( + "same_type_decimal", + doc={"a": Decimal128("10.5"), "b": Decimal128("20.5")}, + expression={"$add": ["$a", "$b"]}, + expected=Decimal128("31.0"), + msg="$add should add two decimal128 values", + ), +] + +# Property [Type Promotion]: $add promotes to the wider type when operands have different numeric +# types. Precedence: decimal128 > double > int64 > int32. +ADD_MIXED_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int32_int64", + doc={"a": 1, "b": Int64(20)}, + expression={"$add": ["$a", "$b"]}, + expected=Int64(21), + msg="$add should return int64 when adding int32 and int64", + ), + ExpressionTestCase( + "int32_double", + doc={"a": 1, "b": 2.5}, + expression={"$add": ["$a", "$b"]}, + expected=3.5, + msg="$add should return double when adding int32 and double", + ), + ExpressionTestCase( + "int32_decimal", + doc={"a": 1, "b": Decimal128("2.5")}, + expression={"$add": ["$a", "$b"]}, + expected=Decimal128("3.5"), + msg="$add should return decimal128 when adding int32 and decimal128", + ), + ExpressionTestCase( + "int64_double", + doc={"a": Int64(10), "b": 2.5}, + expression={"$add": ["$a", "$b"]}, + expected=12.5, + msg="$add should return double when adding int64 and double", + ), + ExpressionTestCase( + "int64_decimal", + doc={"a": Int64(10), "b": Decimal128("2.5")}, + expression={"$add": ["$a", "$b"]}, + expected=Decimal128("12.5"), + msg="$add should return decimal128 when adding int64 and decimal128", + ), + ExpressionTestCase( + "double_decimal", + doc={"a": 1.5, "b": Decimal128("2.5")}, + expression={"$add": ["$a", "$b"]}, + expected=pytest.approx(Decimal128("4.00000000000000")), + msg="$add should return decimal128 when adding double and decimal128", + ), + ExpressionTestCase( + "three_mixed_types", + doc={"a": Decimal128("1.5"), "b": 2.5, "c": Int64(3)}, + expression={"$add": ["$a", "$b", "$c"]}, + expected=pytest.approx(Decimal128("7.00000000000000")), + msg="$add should return decimal128 when adding decimal128, double, and int64", + ), +] + +# Property [Multiple Operands]: $add correctly sums three or more operands. +ADD_MULTIPLE_OPERANDS_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "multiple_int32", + doc={"a": 1, "b": 2, "c": 3}, + expression={"$add": ["$a", "$b", "$c"]}, + expected=6, + msg="$add should add multiple int32 values", + ), + ExpressionTestCase( + "multiple_int64", + doc={"a": Int64(1), "b": Int64(2), "c": Int64(3)}, + expression={"$add": ["$a", "$b", "$c"]}, + expected=Int64(6), + msg="$add should add multiple int64 values", + ), + ExpressionTestCase( + "multiple_double", + doc={"a": 1.1, "b": 2.2, "c": 3.3}, + expression={"$add": ["$a", "$b", "$c"]}, + expected=pytest.approx(6.6), + msg="$add should add multiple double values", + ), + ExpressionTestCase( + "multiple_decimal", + doc={ + "a": Decimal128("1"), + "b": Decimal128("2"), + "c": Decimal128("3"), + "d": Decimal128("4"), + }, + expression={"$add": ["$a", "$b", "$c", "$d"]}, + expected=Decimal128("10"), + msg="$add should add multiple decimal128 values", + ), + ExpressionTestCase( + "five_operands", + doc={"a": 1, "b": 2, "c": 3, "d": 4, "e": 5}, + expression={"$add": ["$a", "$b", "$c", "$d", "$e"]}, + expected=15, + msg="$add should correctly sum five int32 operands", + ), + ExpressionTestCase( + "ten_operands", + doc={ + "a": 1, + "b": 2, + "c": 3, + "d": 4, + "e": 5, + "f": 6, + "g": 7, + "h": 8, + "i": 9, + "j": 10, + }, + expression={"$add": ["$a", "$b", "$c", "$d", "$e", "$f", "$g", "$h", "$i", "$j"]}, + expected=55, + msg="$add should correctly sum ten int32 operands", + ), +] + +# Property [Empty and Single Operand]: $add of zero operands returns 0; single operand returns +# that value unchanged. +ADD_SINGLE_AND_EMPTY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "empty", + doc={}, + expression={"$add": []}, + expected=0, + msg="$add should return 0 for empty operand list", + ), + ExpressionTestCase( + "single_int32", + doc={"a": 5}, + expression={"$add": ["$a"]}, + expected=5, + msg="$add should return the value for a single int32 operand", + ), + ExpressionTestCase( + "single_int64", + doc={"a": Int64(0)}, + expression={"$add": ["$a"]}, + expected=Int64(0), + msg="$add should return the value for a single int64 operand", + ), + ExpressionTestCase( + "single_double", + doc={"a": 0.0}, + expression={"$add": ["$a"]}, + expected=0.0, + msg="$add should return the value for a single double operand", + ), + ExpressionTestCase( + "single_decimal", + doc={"a": Decimal128("0")}, + expression={"$add": ["$a"]}, + expected=Decimal128("0"), + msg="$add should return the value for a single decimal128 operand", + ), +] + +# Property [Sign Handling]: $add handles negative values and zero correctly. +ADD_SIGN_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "negative_positive", + doc={"a": -5, "b": 3}, + expression={"$add": ["$a", "$b"]}, + expected=-2, + msg="$add should add a negative and a positive int32 value", + ), + ExpressionTestCase( + "both_negative", + doc={"a": -10, "b": -20}, + expression={"$add": ["$a", "$b"]}, + expected=-30, + msg="$add should add two negative int32 values", + ), + ExpressionTestCase( + "zeros", + doc={"a": 0, "b": 0}, + expression={"$add": ["$a", "$b"]}, + expected=0, + msg="$add should return 0 when adding two int32 zeros", + ), + ExpressionTestCase( + "zero_negative_zero", + doc={"a": 0, "b": -0.0}, + expression={"$add": ["$a", "$b"]}, + expected=0.0, + msg="$add should return 0.0 when adding int32 zero and negative zero double", + ), + ExpressionTestCase( + "sum_to_zero", + doc={"a": 1, "b": 0, "c": -1}, + expression={"$add": ["$a", "$b", "$c"]}, + expected=0, + msg="$add should return 0 when operands sum to zero", + ), +] + +ADD_NUMERIC_ALL_TESTS = ( + ADD_SAME_TYPE_TESTS + + ADD_MIXED_TYPE_TESTS + + ADD_MULTIPLE_OPERANDS_TESTS + + ADD_SINGLE_AND_EMPTY_TESTS + + ADD_SIGN_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(ADD_NUMERIC_ALL_TESTS)) +def test_add_numeric(collection, test_case: ExpressionTestCase): + """Test $add numeric type combinations, multiple operands, and sign handling.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_overflow.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_overflow.py new file mode 100644 index 000000000..2da84306e --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_overflow.py @@ -0,0 +1,97 @@ +"""Tests for $add integer and double overflow and underflow promotion.""" + +import pytest +from bson import Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DOUBLE_FROM_INT64_MAX, + FLOAT_INFINITY, + FLOAT_NEGATIVE_INFINITY, + INT32_MAX, + INT32_MIN, + INT32_OVERFLOW, + INT32_UNDERFLOW, + INT64_MAX, + INT64_MIN, +) + +# Property [Int32 Overflow]: when an int32 result exceeds the int32 range, $add promotes to +# int64. +ADD_INT32_OVERFLOW_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int32_overflow", + doc={"a": INT32_MAX, "b": 1}, + expression={"$add": ["$a", "$b"]}, + expected=Int64(INT32_OVERFLOW), + msg="$add should promote to int64 when the int32 result overflows INT32_MAX", + ), + ExpressionTestCase( + "int32_underflow", + doc={"a": INT32_MIN, "b": -1}, + expression={"$add": ["$a", "$b"]}, + expected=Int64(INT32_UNDERFLOW), + msg="$add should promote to int64 when the int32 result underflows INT32_MIN", + ), +] + +# Property [Int64 Overflow]: when an int64 result exceeds the int64 range, $add promotes to +# double. +ADD_INT64_OVERFLOW_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int64_overflow", + doc={"a": INT64_MAX, "b": 1}, + expression={"$add": ["$a", "$b"]}, + expected=pytest.approx(DOUBLE_FROM_INT64_MAX), + msg="$add should promote to double when the int64 result overflows INT64_MAX", + ), + ExpressionTestCase( + "int64_underflow", + doc={"a": INT64_MIN, "b": -1}, + expression={"$add": ["$a", "$b"]}, + expected=pytest.approx(-DOUBLE_FROM_INT64_MAX), + msg="$add should promote to double when the int64 result underflows INT64_MIN", + ), +] + +# Property [Double Overflow]: when a double result exceeds the double range, $add returns +# infinity. +ADD_DOUBLE_OVERFLOW_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "double_overflow", + doc={"a": 1e308, "b": 1e308}, + expression={"$add": ["$a", "$b"]}, + expected=FLOAT_INFINITY, + msg="$add should return positive infinity on double overflow", + ), + ExpressionTestCase( + "double_underflow", + doc={"a": -1e308, "b": -1e308}, + expression={"$add": ["$a", "$b"]}, + expected=FLOAT_NEGATIVE_INFINITY, + msg="$add should return negative infinity on double underflow", + ), +] + +ADD_OVERFLOW_ALL_TESTS = ( + ADD_INT32_OVERFLOW_TESTS + ADD_INT64_OVERFLOW_TESTS + ADD_DOUBLE_OVERFLOW_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(ADD_OVERFLOW_ALL_TESTS)) +def test_add_overflow(collection, test_case: ExpressionTestCase): + """Test $add integer and double overflow and underflow cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_precision.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_precision.py new file mode 100644 index 000000000..6658eb03a --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_precision.py @@ -0,0 +1,105 @@ +"""Tests for $add decimal128 precision and boundary value handling.""" + +import pytest +from bson import Decimal128 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_MAX, + DECIMAL128_MIN, + DECIMAL128_NEGATIVE_INFINITY, +) + +# Property [Decimal128 Precision]: $add preserves decimal128 precision, including exact +# representation of values that are inexact in double. +ADD_DECIMAL_PRECISION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "decimal_precision", + doc={"a": Decimal128("1.5"), "b": Decimal128("2.5")}, + expression={"$add": ["$a", "$b"]}, + expected=Decimal128("4.0"), + msg="$add should preserve decimal128 precision", + ), + ExpressionTestCase( + "decimal_precision_small", + doc={"a": Decimal128("0.1"), "b": Decimal128("0.2")}, + expression={"$add": ["$a", "$b"]}, + expected=Decimal128("0.3"), + msg="$add should exactly represent 0.1 + 0.2 with decimal128", + ), + ExpressionTestCase( + "decimal_large_precision", + doc={ + "a": Decimal128("999999999999999999999999999999999"), + "b": Decimal128("1"), + }, + expression={"$add": ["$a", "$b"]}, + expected=Decimal128("1000000000000000000000000000000000"), + msg="$add should handle large decimal128 addition with full precision", + ), + ExpressionTestCase( + "decimal_large_negative_precision", + doc={ + "a": Decimal128("-999999999999999999999999999999999"), + "b": Decimal128("-1"), + }, + expression={"$add": ["$a", "$b"]}, + expected=Decimal128("-1000000000000000000000000000000000"), + msg="$add should handle large negative decimal128 addition with full precision", + ), +] + +# Property [Decimal128 Boundaries]: $add at decimal128 boundary values promotes to infinity when +# the result overflows, and returns zero when max and min cancel. +ADD_DECIMAL_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "decimal128_max_plus_zero", + doc={"a": DECIMAL128_MAX, "b": Decimal128("0")}, + expression={"$add": ["$a", "$b"]}, + expected=DECIMAL128_MAX, + msg="$add should return decimal128 max when adding zero to decimal128 max", + ), + ExpressionTestCase( + "decimal128_max_plus_max", + doc={"a": DECIMAL128_MAX, "b": DECIMAL128_MAX}, + expression={"$add": ["$a", "$b"]}, + expected=DECIMAL128_INFINITY, + msg="$add should return decimal128 infinity when adding two decimal128 max values", + ), + ExpressionTestCase( + "decimal128_min_plus_min", + doc={"a": DECIMAL128_MIN, "b": DECIMAL128_MIN}, + expression={"$add": ["$a", "$b"]}, + expected=DECIMAL128_NEGATIVE_INFINITY, + msg="$add should return decimal128 negative infinity when adding two decimal128 min values", + ), + ExpressionTestCase( + "decimal128_max_plus_min", + doc={"a": DECIMAL128_MAX, "b": DECIMAL128_MIN}, + expression={"$add": ["$a", "$b"]}, + expected=Decimal128("0E+6111"), + msg="$add should return zero when adding decimal128 max and decimal128 min", + ), +] + +ADD_PRECISION_ALL_TESTS = ADD_DECIMAL_PRECISION_TESTS + ADD_DECIMAL_BOUNDARY_TESTS + + +@pytest.mark.parametrize("test_case", pytest_params(ADD_PRECISION_ALL_TESTS)) +def test_add_precision(collection, test_case: ExpressionTestCase): + """Test $add decimal128 precision and boundary value cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_return_type.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_return_type.py new file mode 100644 index 000000000..d681c7fd2 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_return_type.py @@ -0,0 +1,116 @@ +"""Tests for $add return type promotion rules across numeric and date operand combinations.""" + +from datetime import datetime, timezone + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Return Type]: $add follows numeric type promotion rules to determine the result type. +# Precedence: decimal128 > double > int64 > int32. Date + numeric always returns date. +ADD_RETURN_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "return_type_int_int", + doc={"a": 1, "b": 2}, + expression={"$type": {"$add": ["$a", "$b"]}}, + expected="int", + msg="$add should return int type when adding two int32 values", + ), + ExpressionTestCase( + "return_type_int_long", + doc={"a": 1, "b": Int64(2)}, + expression={"$type": {"$add": ["$a", "$b"]}}, + expected="long", + msg="$add should return long type when adding int32 and int64", + ), + ExpressionTestCase( + "return_type_int_double", + doc={"a": 1, "b": 2.0}, + expression={"$type": {"$add": ["$a", "$b"]}}, + expected="double", + msg="$add should return double type when adding int32 and double", + ), + ExpressionTestCase( + "return_type_int_decimal", + doc={"a": 1, "b": Decimal128("2")}, + expression={"$type": {"$add": ["$a", "$b"]}}, + expected="decimal", + msg="$add should return decimal type when adding int32 and decimal128", + ), + ExpressionTestCase( + "return_type_long_long", + doc={"a": Int64(1), "b": Int64(2)}, + expression={"$type": {"$add": ["$a", "$b"]}}, + expected="long", + msg="$add should return long type when adding two int64 values", + ), + ExpressionTestCase( + "return_type_long_double", + doc={"a": Int64(1), "b": 2.0}, + expression={"$type": {"$add": ["$a", "$b"]}}, + expected="double", + msg="$add should return double type when adding int64 and double", + ), + ExpressionTestCase( + "return_type_long_decimal", + doc={"a": Int64(1), "b": Decimal128("2")}, + expression={"$type": {"$add": ["$a", "$b"]}}, + expected="decimal", + msg="$add should return decimal type when adding int64 and decimal128", + ), + ExpressionTestCase( + "return_type_double_double", + doc={"a": 1.0, "b": 2.0}, + expression={"$type": {"$add": ["$a", "$b"]}}, + expected="double", + msg="$add should return double type when adding two double values", + ), + ExpressionTestCase( + "return_type_double_decimal", + doc={"a": 1.0, "b": Decimal128("2")}, + expression={"$type": {"$add": ["$a", "$b"]}}, + expected="decimal", + msg="$add should return decimal type when adding double and decimal128", + ), + ExpressionTestCase( + "return_type_decimal_decimal", + doc={"a": Decimal128("1"), "b": Decimal128("2")}, + expression={"$type": {"$add": ["$a", "$b"]}}, + expected="decimal", + msg="$add should return decimal type when adding two decimal128 values", + ), + ExpressionTestCase( + "return_type_date_int", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": 1000}, + expression={"$type": {"$add": ["$a", "$b"]}}, + expected="date", + msg="$add should return date type when adding a date and an int32", + ), + ExpressionTestCase( + "return_type_empty", + doc={}, + expression={"$type": {"$add": []}}, + expected="int", + msg="$add should return int type for an empty operand list", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(ADD_RETURN_TYPE_TESTS)) +def test_add_return_type(collection, test_case: ExpressionTestCase): + """Test $add return type promotion rules for all numeric type combinations.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ceil/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ceil/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ceil/test_ceil_boundaries.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ceil/test_ceil_boundaries.py new file mode 100644 index 000000000..151f42977 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ceil/test_ceil_boundaries.py @@ -0,0 +1,237 @@ +"""Tests for $ceil at representable-range boundaries for int32, int64, double, and decimal128.""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_JUST_ABOVE_HALF, + DECIMAL128_JUST_BELOW_HALF, + DECIMAL128_LARGE_EXPONENT, + DECIMAL128_MANY_TRAILING_ZEROS, + DECIMAL128_MAX, + DECIMAL128_MIN, + DECIMAL128_NAN, + DECIMAL128_SMALL_EXPONENT, + DECIMAL128_TRAILING_ZERO, + DOUBLE_JUST_BELOW_HALF, + DOUBLE_MAX_SAFE_INTEGER, + DOUBLE_MIN_NEGATIVE_SUBNORMAL, + DOUBLE_MIN_SUBNORMAL, + DOUBLE_NEAR_MAX, + DOUBLE_NEAR_MIN, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_PRECISION_LOSS, + INT32_MAX, + INT32_MIN, + INT32_OVERFLOW, + INT32_UNDERFLOW, + INT64_MAX, + INT64_MAX_MINUS_1, + INT64_MIN, + INT64_MIN_PLUS_1, +) + +# Property [Int32 Boundaries]: $ceil of an integer returns the same integer unchanged. +CEIL_INT32_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int32_max", + doc={"value": INT32_MAX}, + expression={"$ceil": ["$value"]}, + expected=INT32_MAX, + msg="$ceil should return INT32_MAX unchanged", + ), + ExpressionTestCase( + "int32_min", + doc={"value": INT32_MIN}, + expression={"$ceil": ["$value"]}, + expected=INT32_MIN, + msg="$ceil should return INT32_MIN unchanged", + ), + ExpressionTestCase( + "int32_overflow", + doc={"value": INT32_OVERFLOW}, + expression={"$ceil": ["$value"]}, + expected=Int64(INT32_OVERFLOW), + msg="$ceil should return INT32_OVERFLOW unchanged as int64", + ), + ExpressionTestCase( + "int32_underflow", + doc={"value": INT32_UNDERFLOW}, + expression={"$ceil": ["$value"]}, + expected=Int64(INT32_UNDERFLOW), + msg="$ceil should return INT32_UNDERFLOW unchanged as int64", + ), +] + +# Property [Int64 Boundaries]: $ceil of an int64 integer returns the same int64 unchanged. +CEIL_INT64_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int64_max", + doc={"value": INT64_MAX}, + expression={"$ceil": ["$value"]}, + expected=INT64_MAX, + msg="$ceil should return INT64_MAX unchanged", + ), + ExpressionTestCase( + "int64_max_minus_1", + doc={"value": INT64_MAX_MINUS_1}, + expression={"$ceil": ["$value"]}, + expected=INT64_MAX_MINUS_1, + msg="$ceil should return INT64_MAX-1 unchanged", + ), + ExpressionTestCase( + "int64_min", + doc={"value": INT64_MIN}, + expression={"$ceil": ["$value"]}, + expected=INT64_MIN, + msg="$ceil should return INT64_MIN unchanged", + ), + ExpressionTestCase( + "int64_min_plus_1", + doc={"value": INT64_MIN_PLUS_1}, + expression={"$ceil": ["$value"]}, + expected=INT64_MIN_PLUS_1, + msg="$ceil should return INT64_MIN+1 unchanged", + ), +] + +# Property [Double Boundaries]: $ceil rounds double boundary values up to the nearest integer. +CEIL_DOUBLE_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "double_min_subnormal", + doc={"value": DOUBLE_MIN_SUBNORMAL}, + expression={"$ceil": ["$value"]}, + expected=1.0, + msg="$ceil should round the minimum subnormal double up to 1.0", + ), + ExpressionTestCase( + "double_min_negative_subnormal", + doc={"value": DOUBLE_MIN_NEGATIVE_SUBNORMAL}, + expression={"$ceil": ["$value"]}, + expected=DOUBLE_NEGATIVE_ZERO, + msg="$ceil should round the minimum negative subnormal double up to negative zero", + ), + ExpressionTestCase( + "double_near_min", + doc={"value": DOUBLE_NEAR_MIN}, + expression={"$ceil": ["$value"]}, + expected=1.0, + msg="$ceil should round a near-min double up to 1.0", + ), + ExpressionTestCase( + "double_near_max", + doc={"value": DOUBLE_NEAR_MAX}, + expression={"$ceil": ["$value"]}, + expected=DOUBLE_NEAR_MAX, + msg="$ceil should return the same value for a near-max double", + ), + ExpressionTestCase( + "double_max_safe_integer", + doc={"value": float(DOUBLE_MAX_SAFE_INTEGER)}, + expression={"$ceil": ["$value"]}, + expected=float(DOUBLE_MAX_SAFE_INTEGER), + msg="$ceil should return the max safe integer double unchanged", + ), + ExpressionTestCase( + "double_precision_loss", + doc={"value": float(DOUBLE_PRECISION_LOSS)}, + expression={"$ceil": ["$value"]}, + expected=float(DOUBLE_PRECISION_LOSS), + msg="$ceil should return the precision loss double unchanged", + ), + ExpressionTestCase( + "double_fraction_between_zero_and_one", + doc={"value": DOUBLE_JUST_BELOW_HALF}, + expression={"$ceil": ["$value"]}, + expected=1.0, + msg="$ceil should round a double fraction in (0,1) up to 1.0", + ), +] + +# Property [Decimal128 Boundaries]: $ceil rounds decimal128 boundary values up to the nearest +# integer, returning NaN when the value overflows the representable range. +CEIL_DECIMAL_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "decimal128_max", + doc={"value": DECIMAL128_MAX}, + expression={"$ceil": ["$value"]}, + expected=DECIMAL128_NAN, + msg="$ceil should return NaN for decimal128 max (overflow)", + ), + ExpressionTestCase( + "decimal128_min", + doc={"value": DECIMAL128_MIN}, + expression={"$ceil": ["$value"]}, + expected=DECIMAL128_NAN, + msg="$ceil should return NaN for decimal128 min (overflow)", + ), + ExpressionTestCase( + "decimal128_large_exponent", + doc={"value": DECIMAL128_LARGE_EXPONENT}, + expression={"$ceil": ["$value"]}, + expected=DECIMAL128_NAN, + msg="$ceil should return NaN for a decimal128 with a large exponent (overflow)", + ), + ExpressionTestCase( + "decimal128_small_exponent", + doc={"value": DECIMAL128_SMALL_EXPONENT}, + expression={"$ceil": ["$value"]}, + expected=Decimal128("1"), + msg="$ceil should round a decimal128 with a small exponent up to 1", + ), + ExpressionTestCase( + "decimal128_trailing_zero", + doc={"value": DECIMAL128_TRAILING_ZERO}, + expression={"$ceil": ["$value"]}, + expected=Decimal128("1"), + msg="$ceil should round a decimal128 with a trailing zero up to 1", + ), + ExpressionTestCase( + "decimal128_many_trailing_zeros", + doc={"value": DECIMAL128_MANY_TRAILING_ZEROS}, + expression={"$ceil": ["$value"]}, + expected=Decimal128("1"), + msg="$ceil should round a decimal128 with many trailing zeros up to 1", + ), + ExpressionTestCase( + "decimal128_just_below_half", + doc={"value": DECIMAL128_JUST_BELOW_HALF}, + expression={"$ceil": ["$value"]}, + expected=Decimal128("1"), + msg="$ceil should round a decimal128 just below half up to 1", + ), + ExpressionTestCase( + "decimal128_just_above_half", + doc={"value": DECIMAL128_JUST_ABOVE_HALF}, + expression={"$ceil": ["$value"]}, + expected=Decimal128("1"), + msg="$ceil should round a decimal128 just above half up to 1", + ), +] + +CEIL_BOUNDARY_ALL_TESTS = ( + CEIL_INT32_BOUNDARY_TESTS + + CEIL_INT64_BOUNDARY_TESTS + + CEIL_DOUBLE_BOUNDARY_TESTS + + CEIL_DECIMAL_BOUNDARY_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(CEIL_BOUNDARY_ALL_TESTS)) +def test_ceil_boundaries(collection, test_case: ExpressionTestCase): + """Test $ceil representable-range boundary cases for all numeric types.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ceil/test_ceil_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ceil/test_ceil_errors.py new file mode 100644 index 000000000..7ce6b9b19 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ceil/test_ceil_errors.py @@ -0,0 +1,89 @@ +"""Tests for $ceil type mismatch and arity error cases.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.bson_type_validator import ( + BsonType, + BsonTypeTestCase, + generate_bson_rejection_test_cases, +) +from documentdb_tests.framework.error_codes import ( + EXPRESSION_TYPE_MISMATCH_ERROR, + NON_NUMERIC_TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params + +_NUMERIC_AND_NULL_TYPES = [ + BsonType.DOUBLE, + BsonType.INT, + BsonType.LONG, + BsonType.DECIMAL, + BsonType.NULL, +] + +# Property [Type Strictness]: $ceil rejects all non-numeric, non-null BSON types. +CEIL_TYPE_SPEC = BsonTypeTestCase( + id="ceil_input", + msg="$ceil should reject non-numeric input", + keyword="$ceil", + valid_types=_NUMERIC_AND_NULL_TYPES, + default_error_code=NON_NUMERIC_TYPE_MISMATCH_ERROR, +) + +CEIL_TYPE_REJECTION_CASES = generate_bson_rejection_test_cases([CEIL_TYPE_SPEC]) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", CEIL_TYPE_REJECTION_CASES) +def test_ceil_rejects_non_numeric_input(collection, bson_type, sample_value, spec): + """Test $ceil rejects non-numeric BSON types.""" + result = execute_expression_with_insert( + collection, {"$ceil": ["$value"]}, {"value": sample_value} + ) + assertFailureCode(result, spec.expected_code(bson_type), msg=spec.msg) + + +# Property [Array Input]: $ceil rejects array-typed input; it does not apply element-wise. +# Property [Arity]: $ceil requires exactly one argument. +CEIL_ARGUMENT_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "composite_array_field_path", + doc={"a": [{"b": 1.5}]}, + expression={"$ceil": "$a.b"}, + error_code=NON_NUMERIC_TYPE_MISMATCH_ERROR, + msg="$ceil should reject a field path that resolves to an array", + ), + ExpressionTestCase( + "arity_zero", + doc={}, + expression={"$ceil": []}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="$ceil should reject zero arguments", + ), + ExpressionTestCase( + "arity_two", + doc={}, + expression={"$ceil": [1, 2]}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="$ceil should reject two arguments", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(CEIL_ARGUMENT_ERROR_TESTS)) +def test_ceil_argument_errors(collection, test_case: ExpressionTestCase): + """Test $ceil rejects invalid argument count and array-typed input.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ceil/test_ceil_input_forms.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ceil/test_ceil_input_forms.py new file mode 100644 index 000000000..081deadd6 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ceil/test_ceil_input_forms.py @@ -0,0 +1,76 @@ +"""Tests for $ceil argument forms: array-wrapped, bare, literal, and nested expression inputs.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Argument Form]: $ceil accepts its single argument bare or wrapped in a one-element +# array. +CEIL_ARGUMENT_FORM_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "form_array", + doc={"value": 1.5}, + expression={"$ceil": ["$value"]}, + expected=2.0, + msg="$ceil should accept its argument wrapped in a one-element array", + ), + ExpressionTestCase( + "form_bare", + doc={"value": 1.5}, + expression={"$ceil": "$value"}, + expected=2.0, + msg="$ceil should accept its argument without an array wrapper", + ), +] + +# Property [Literal Input]: $ceil evaluates an inline literal argument, not only document fields. +CEIL_LITERAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "literal_input", + doc={}, + expression={"$ceil": [4.1]}, + expected=5.0, + msg="$ceil should return the ceiling of an inline literal argument", + ), +] + +# Property [Expression Input]: $ceil evaluates a nested expression argument before rounding up. +CEIL_EXPRESSION_INPUT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_ceil", + doc={"value": 4.1}, + expression={"$ceil": {"$ceil": "$value"}}, + expected=5.0, + msg="$ceil should evaluate a nested $ceil expression argument", + ), + ExpressionTestCase( + "object_expression_input", + doc={"value": 1.5}, + expression={"$ceil": {"$multiply": ["$value", 1.0]}}, + expected=2.0, + msg="$ceil should accept an object expression as its argument", + ), +] + +CEIL_INPUT_FORM_ALL_TESTS = ( + CEIL_ARGUMENT_FORM_TESTS + CEIL_LITERAL_TESTS + CEIL_EXPRESSION_INPUT_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(CEIL_INPUT_FORM_ALL_TESTS)) +def test_ceil_input_forms(collection, test_case: ExpressionTestCase): + """Test $ceil argument form, literal, and nested expression input cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ceil/test_ceil_non_finite.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ceil/test_ceil_non_finite.py new file mode 100644 index 000000000..3a8f0c8dc --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ceil/test_ceil_non_finite.py @@ -0,0 +1,87 @@ +"""Tests for $ceil with infinity and NaN inputs for double and decimal128 types.""" + +import math + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, +) + +# Property [Infinity]: $ceil of positive or negative float infinity returns the same infinity. +# $ceil of decimal128 infinity returns NaN (overflow during rounding). +CEIL_INFINITY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "float_infinity", + doc={"value": FLOAT_INFINITY}, + expression={"$ceil": ["$value"]}, + expected=FLOAT_INFINITY, + msg="$ceil should return float infinity for float infinity", + ), + ExpressionTestCase( + "float_negative_infinity", + doc={"value": FLOAT_NEGATIVE_INFINITY}, + expression={"$ceil": ["$value"]}, + expected=FLOAT_NEGATIVE_INFINITY, + msg="$ceil should return float negative infinity for float negative infinity", + ), + ExpressionTestCase( + "decimal128_infinity", + doc={"value": DECIMAL128_INFINITY}, + expression={"$ceil": ["$value"]}, + expected=DECIMAL128_NAN, + msg="$ceil should return NaN for decimal128 infinity", + ), + ExpressionTestCase( + "decimal128_negative_infinity", + doc={"value": DECIMAL128_NEGATIVE_INFINITY}, + expression={"$ceil": ["$value"]}, + expected=DECIMAL128_NAN, + msg="$ceil should return NaN for decimal128 negative infinity", + ), +] + +# Property [NaN]: $ceil of NaN returns NaN of the same type. +CEIL_NAN_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "float_nan", + doc={"value": FLOAT_NAN}, + expression={"$ceil": ["$value"]}, + expected=pytest.approx(math.nan, nan_ok=True), + msg="$ceil should return NaN for float NaN", + ), + ExpressionTestCase( + "decimal128_nan", + doc={"value": DECIMAL128_NAN}, + expression={"$ceil": ["$value"]}, + expected=DECIMAL128_NAN, + msg="$ceil should return NaN for decimal128 NaN", + ), +] + +CEIL_NON_FINITE_TESTS = CEIL_INFINITY_TESTS + CEIL_NAN_TESTS + + +@pytest.mark.parametrize("test_case", pytest_params(CEIL_NON_FINITE_TESTS)) +def test_ceil_non_finite(collection, test_case: ExpressionTestCase): + """Test $ceil infinity and NaN cases for double and decimal128.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ceil/test_ceil_null.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ceil/test_ceil_null.py new file mode 100644 index 000000000..ef29f5126 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ceil/test_ceil_null.py @@ -0,0 +1,45 @@ +"""Tests for $ceil null and missing field propagation.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + MISSING, +) + +# Property [Null Propagation]: $ceil of null or a missing field returns null. +CEIL_NULL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_value", + doc={"value": None}, + expression={"$ceil": ["$value"]}, + expected=None, + msg="$ceil should return null for null input", + ), + ExpressionTestCase( + "missing_field", + doc={}, + expression={"$ceil": [MISSING]}, + expected=None, + msg="$ceil should return null for a missing field", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(CEIL_NULL_TESTS)) +def test_ceil_null(collection, test_case: ExpressionTestCase): + """Test $ceil null and missing field propagation cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ceil/test_ceil_numeric.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ceil/test_ceil_numeric.py new file mode 100644 index 000000000..58d709142 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ceil/test_ceil_numeric.py @@ -0,0 +1,162 @@ +"""Tests for $ceil basic numeric rounding across int32, int64, double, and decimal128 types.""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_NEGATIVE_ZERO, + DOUBLE_NEGATIVE_ZERO, +) + +# Property [Integer Identity]: $ceil of any integer value returns the same integer unchanged. +CEIL_INTEGER_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "positive_int32", + doc={"value": 1}, + expression={"$ceil": ["$value"]}, + expected=1, + msg="$ceil should return the same value for a positive int32", + ), + ExpressionTestCase( + "negative_int32", + doc={"value": -1}, + expression={"$ceil": ["$value"]}, + expected=-1, + msg="$ceil should return the same value for a negative int32", + ), + ExpressionTestCase( + "zero_int32", + doc={"value": 0}, + expression={"$ceil": ["$value"]}, + expected=0, + msg="$ceil should return zero for int32 zero", + ), + ExpressionTestCase( + "positive_int64", + doc={"value": Int64(1)}, + expression={"$ceil": ["$value"]}, + expected=Int64(1), + msg="$ceil should return the same value for a positive int64", + ), + ExpressionTestCase( + "negative_int64", + doc={"value": Int64(-1)}, + expression={"$ceil": ["$value"]}, + expected=Int64(-1), + msg="$ceil should return the same value for a negative int64", + ), + ExpressionTestCase( + "zero_int64", + doc={"value": Int64(0)}, + expression={"$ceil": ["$value"]}, + expected=Int64(0), + msg="$ceil should return zero for int64 zero", + ), +] + +# Property [Double Rounding]: $ceil rounds a double up to the nearest integer double. +CEIL_DOUBLE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "positive_double", + doc={"value": 1.5}, + expression={"$ceil": ["$value"]}, + expected=2.0, + msg="$ceil should round up a positive double to the next integer", + ), + ExpressionTestCase( + "negative_double", + doc={"value": -1.5}, + expression={"$ceil": ["$value"]}, + expected=-1.0, + msg="$ceil should round a negative double toward zero", + ), + ExpressionTestCase( + "zero_double", + doc={"value": 0.0}, + expression={"$ceil": ["$value"]}, + expected=0.0, + msg="$ceil should return zero for double zero", + ), + ExpressionTestCase( + "negative_zero_double", + doc={"value": DOUBLE_NEGATIVE_ZERO}, + expression={"$ceil": ["$value"]}, + expected=DOUBLE_NEGATIVE_ZERO, + msg="$ceil should return zero for negative zero double", + ), + ExpressionTestCase( + "negative_fraction", + doc={"value": -0.5}, + expression={"$ceil": ["$value"]}, + expected=-0.0, + msg="$ceil should round a negative double fraction up to negative zero", + ), +] + +# Property [Decimal128 Rounding]: $ceil rounds a decimal128 up to the nearest integer decimal128. +CEIL_DECIMAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "positive_decimal", + doc={"value": Decimal128("1")}, + expression={"$ceil": ["$value"]}, + expected=Decimal128("1"), + msg="$ceil should return the same value for a positive whole decimal128", + ), + ExpressionTestCase( + "negative_decimal", + doc={"value": Decimal128("-1")}, + expression={"$ceil": ["$value"]}, + expected=Decimal128("-1"), + msg="$ceil should return the same value for a negative whole decimal128", + ), + ExpressionTestCase( + "zero_decimal", + doc={"value": Decimal128("0")}, + expression={"$ceil": ["$value"]}, + expected=Decimal128("0"), + msg="$ceil should return zero for decimal128 zero", + ), + ExpressionTestCase( + "negative_zero_decimal", + doc={"value": DECIMAL128_NEGATIVE_ZERO}, + expression={"$ceil": ["$value"]}, + expected=DECIMAL128_NEGATIVE_ZERO, + msg="$ceil should return negative zero for negative zero decimal128", + ), + ExpressionTestCase( + "positive_decimal_fraction", + doc={"value": Decimal128("1.5")}, + expression={"$ceil": ["$value"]}, + expected=Decimal128("2"), + msg="$ceil should round up a positive decimal128 fraction", + ), + ExpressionTestCase( + "negative_decimal_fraction", + doc={"value": Decimal128("-1.5")}, + expression={"$ceil": ["$value"]}, + expected=Decimal128("-1"), + msg="$ceil should round a negative decimal128 fraction toward zero", + ), +] + +CEIL_NUMERIC_ALL_TESTS = CEIL_INTEGER_TESTS + CEIL_DOUBLE_TESTS + CEIL_DECIMAL_TESTS + + +@pytest.mark.parametrize("test_case", pytest_params(CEIL_NUMERIC_ALL_TESTS)) +def test_ceil_numeric(collection, test_case: ExpressionTestCase): + """Test $ceil basic numeric rounding for integer, double, and decimal128 inputs.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ceil/test_ceil_return_type.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ceil/test_ceil_return_type.py new file mode 100644 index 000000000..9d46d3ec6 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ceil/test_ceil_return_type.py @@ -0,0 +1,57 @@ +"""Tests for $ceil return type preservation across numeric types.""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Return Type]: $ceil preserves the numeric type of its input. +CEIL_RETURN_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "return_type_int32", + doc={"value": 5}, + expression={"$type": {"$ceil": "$value"}}, + expected="int", + msg="$ceil should preserve int32 type", + ), + ExpressionTestCase( + "return_type_int64", + doc={"value": Int64(5)}, + expression={"$type": {"$ceil": "$value"}}, + expected="long", + msg="$ceil should preserve int64 type", + ), + ExpressionTestCase( + "return_type_double", + doc={"value": 1.5}, + expression={"$type": {"$ceil": "$value"}}, + expected="double", + msg="$ceil should preserve double type", + ), + ExpressionTestCase( + "return_type_decimal", + doc={"value": Decimal128("1.5")}, + expression={"$type": {"$ceil": "$value"}}, + expected="decimal", + msg="$ceil should preserve decimal128 type", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(CEIL_RETURN_TYPE_TESTS)) +def test_ceil_return_type(collection, test_case: ExpressionTestCase): + """Test $ceil return type preservation cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) From 46297dfb1a82accee05476b745cc74eae93ab00a Mon Sep 17 00:00:00 2001 From: Daniel Frankcom Date: Tue, 7 Jul 2026 11:20:33 -0700 Subject: [PATCH 12/35] Add searchMeta stage tests (#638) Signed-off-by: Daniel Frankcom Signed-off-by: RyanGarfinkel <113050972+RyanGarfinkel@users.noreply.github.com> --- .github/workflows/pr-tests.yml | 11 + dev/compose.yaml | 51 +- dev/mongot.yml | 32 ++ .../operator/stages/searchMeta/__init__.py | 0 .../operator/stages/searchMeta/conftest.py | 25 + .../test_searchMeta_collection_states.py | 213 +++++++++ .../searchMeta/test_searchMeta_facets.py | 447 ++++++++++++++++++ .../test_searchMeta_facets_advanced.py | 234 +++++++++ .../searchMeta/test_searchMeta_index.py | 250 ++++++++++ .../searchMeta/test_searchMeta_metadata.py | 288 +++++++++++ .../searchMeta/test_searchMeta_operators.py | 384 +++++++++++++++ .../searchMeta/test_searchMeta_options.py | 90 ++++ .../searchMeta/test_searchMeta_path_forms.py | 145 ++++++ .../test_searchMeta_return_scope.py | 258 ++++++++++ .../test_searchMeta_spec_count_errors.py | 346 ++++++++++++++ ...t_searchMeta_spec_facet_boundary_errors.py | 402 ++++++++++++++++ .../test_searchMeta_spec_facet_errors.py | 414 ++++++++++++++++ .../test_searchMeta_spec_operator_errors.py | 222 +++++++++ .../test_searchMeta_stored_source.py | 171 +++++++ .../test_searchMeta_string_facet.py | 198 ++++++++ .../test_searchMeta_threshold_bounds.py | 113 +++++ .../searchMeta/test_smoke_searchMeta.py | 9 +- .../stages/searchMeta/utils/__init__.py | 0 .../searchMeta/utils/searchMeta_common.py | 110 +++++ .../stages/test_stages_position_searchMeta.py | 300 ++++++++++++ documentdb_tests/framework/engine_registry.py | 37 +- documentdb_tests/framework/preconditions.py | 6 + documentdb_tests/pytest.ini | 1 + 28 files changed, 4747 insertions(+), 10 deletions(-) create mode 100644 dev/mongot.yml create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/conftest.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_collection_states.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_facets.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_facets_advanced.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_index.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_metadata.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_operators.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_options.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_path_forms.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_return_scope.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_spec_count_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_spec_facet_boundary_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_spec_facet_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_spec_operator_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_stored_source.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_string_facet.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_threshold_bounds.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/utils/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/utils/searchMeta_common.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/test_stages_position_searchMeta.py diff --git a/.github/workflows/pr-tests.yml b/.github/workflows/pr-tests.yml index d4f394227..ec0404df2 100644 --- a/.github/workflows/pr-tests.yml +++ b/.github/workflows/pr-tests.yml @@ -61,6 +61,17 @@ jobs: --json-report --json-report-file=${{ github.workspace }}/.test-results/${{ matrix.target.name }}-report.json \ --junitxml=${{ github.workspace }}/.test-results/${{ matrix.target.name }}-results.xml + - name: Dump container logs + if: always() + run: | + # One file per service in the profile + mkdir -p "${{ github.workspace }}/.test-results/container-logs" + for svc in $(docker compose -f dev/compose.yaml --profile ${{ matrix.target.profile }} config --services); do + docker compose -f dev/compose.yaml --profile ${{ matrix.target.profile }} \ + logs --no-color --timestamps "$svc" \ + > "${{ github.workspace }}/.test-results/container-logs/${svc}.log" 2>&1 || true + done + - name: Upload test results if: always() uses: actions/upload-artifact@v7 diff --git a/dev/compose.yaml b/dev/compose.yaml index 4258cecfb..60485a08f 100644 --- a/dev/compose.yaml +++ b/dev/compose.yaml @@ -27,7 +27,7 @@ # query: # # A service with no `x-test-target` is not a test target and is ignored by the -# registry. +# registry (e.g. the mongot sidecar, which is reached only through its mongod). # # Memory: each mongod caps its WiredTiger cache (--wiredTigerCacheSizeGB). By # default a mongod sizes its cache to ~50% of the host/VM RAM; with several @@ -60,7 +60,26 @@ services: mongo-replset: image: mongo:8.2.4 profiles: ["mongo-replset", "all"] - command: ["--replSet", "rs0", "--bind_ip_all", "--wiredTigerCacheSizeGB", "1.5"] + command: + - "--replSet" + - "rs0" + - "--bind_ip_all" + - "--wiredTigerCacheSizeGB" + - "1.5" + # Point at the mongot search sidecar so this replica set also serves the + # search surfaces. mongot is transparent to all other behavior, so the + # set behaves identically to a plain replica set apart from gaining + # search; it is one target, not two. + - "--setParameter" + - "mongotHost=mongot:27028" + - "--setParameter" + - "searchIndexManagementHostAndPort=mongot:27028" + - "--setParameter" + - "useGrpcForSearch=true" + - "--setParameter" + - "skipAuthenticationToMongot=true" + - "--setParameter" + - "skipAuthenticationToSearchIndexManagementServer=true" ports: - "27018:27017" healthcheck: @@ -71,3 +90,31 @@ services: x-test-target: engine: mongodb query: directConnection=true + + # mongot: the search sidecar for the mongo-replset target. Not a test target + # on its own; the suite reaches it only through mongo-replset. mongot is + # MongoDB Search Community Edition (SSPL, same license as the server). It + # replicates from the replica set as an authenticated sync source and reads + # its password from a file, so the entrypoint writes that file (a fixed + # local-dev secret, matched by the searchCoordinator user the harness creates + # on the replica set) with owner-only permissions before launching. It retries + # the connection until that user exists. + mongot: + image: mongodb/mongodb-community-search:1.70.1 + profiles: ["mongo-replset", "all"] + entrypoint: + - "sh" + - "-c" + - > + umask 077 && + mkdir -p /mongot-secrets && + printf '%s' "$$MONGOT_SYNC_PASSWORD" > /mongot-secrets/passwordFile && + exec /mongot-community/mongot --config /mongot-config/mongot.yml + environment: + # Fixed local-dev secret shared with the searchCoordinator user the + # harness provisions on mongo-replset. Not a real credential. + MONGOT_SYNC_PASSWORD: "searchSyncPassword" + # Cap mongot's JVM heap. Unset, the JVM sizes its max heap to ~25% of host RAM. + JAVA_TOOL_OPTIONS: "-Xmx1g" + volumes: + - ./mongot.yml:/mongot-config/mongot.yml:ro diff --git a/dev/mongot.yml b/dev/mongot.yml new file mode 100644 index 000000000..82be59cb2 --- /dev/null +++ b/dev/mongot.yml @@ -0,0 +1,32 @@ +# mongot configuration for the mongo-replset target (dev/compose.yaml service +# "mongot"). mongot is MongoDB Search Community Edition (SSPL), the same license +# as the server. It runs alongside the replica set's mongod and serves the +# search and vector search surfaces. +# +# mongot replicates from the mongod replica set as a sync source. It requires an +# authenticated connection (it has no unauthenticated mode), so it logs in as a +# dedicated user holding the searchCoordinator role. That user and its password +# file are provisioned by the target's startup (see dev/compose.yaml). +syncSource: + replicaSet: + hostAndPort: "mongo-replset:27017" + scramAuth: + username: "searchSyncUser" + authSource: "admin" + passwordFile: "/mongot-secrets/passwordFile" + tls: + enabled: false +storage: + dataPath: "/var/lib/mongot" +server: + grpc: + # mongod reaches mongot here (see mongotHost / searchIndexManagementHostAndPort + # on the mongo-replset service). Bound on all interfaces so the mongod + # container can connect over the compose network. + address: "0.0.0.0:27028" + tls: + mode: "disabled" +healthCheck: + address: "0.0.0.0:8080" +logging: + verbosity: INFO diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/__init__.py b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/conftest.py b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/conftest.py new file mode 100644 index 000000000..2041e3070 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/conftest.py @@ -0,0 +1,25 @@ +"""Shared fixtures for $searchMeta stage tests.""" + +from __future__ import annotations + +from collections.abc import Iterator + +import pytest +from pymongo.collection import Collection + +from documentdb_tests.compatibility.tests.core.operator.stages.searchMeta.utils.searchMeta_common import ( # noqa: E501 + open_search_collection, +) + + +@pytest.fixture(scope="package") +def search_collection(engine_client, worker_id) -> Iterator[Collection]: + """Metadata search collection carrying a ``default`` and a non-default + ``alt_idx`` index, for tests that select an index by name. + + Package-scoped and read-only: the search index is built and polled to READY + once for all $searchMeta tests that request it.""" + with open_search_collection( + engine_client, worker_id, "stages_searchmeta_shared::search_collection" + ) as coll: + yield coll diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_collection_states.py b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_collection_states.py new file mode 100644 index 000000000..6a8975cb0 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_collection_states.py @@ -0,0 +1,213 @@ +"""Tests for $searchMeta on empty, unindexed, and nonexistent collections.""" + +from __future__ import annotations + +from collections.abc import Iterator + +import pytest +from pymongo.collection import Collection + +from documentdb_tests.compatibility.tests.core.operator.stages.searchMeta.utils.searchMeta_common import ( # noqa: E501 + SEARCH_DOCS, + CollectionFixtureTestCase, + build_collection, +) +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.test_constants import INT64_ZERO + +pytestmark = pytest.mark.requires(search=True) + + +@pytest.fixture(scope="module") +def empty_search_collection(engine_client, worker_id) -> Iterator[Collection]: + """Indexed but empty collection.""" + with build_collection( + engine_client, + worker_id, + f"{__name__}::empty_search_collection", + "searchmeta_empty", + [], + [{"name": "default", "definition": {"mappings": {"dynamic": True}}}], + ) as coll: + yield coll + + +# Property [Empty Collection Count]: on an indexed but empty collection, +# $searchMeta returns a zero count respecting the requested count.type, +# defaulting to lowerBound. +SEARCHMETA_EMPTY_COLLECTION_TESTS: list[CollectionFixtureTestCase] = [ + CollectionFixtureTestCase( + "empty_default", + collection_fixture="empty_search_collection", + pipeline=[{"$searchMeta": {"text": {"query": "quick", "path": "title"}}}], + expected=[{"count": {"lowerBound": INT64_ZERO}}], + msg="$searchMeta should default to a lower-bound zero count on an indexed empty " + "collection", + ), + CollectionFixtureTestCase( + "empty_total", + collection_fixture="empty_search_collection", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "count": {"type": "total"}, + } + } + ], + expected=[{"count": {"total": INT64_ZERO}}], + msg="$searchMeta should return a total-zero count on an indexed empty collection when " + "count.type is total", + ), + CollectionFixtureTestCase( + "empty_lower_bound", + collection_fixture="empty_search_collection", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "count": {"type": "lowerBound"}, + } + } + ], + expected=[{"count": {"lowerBound": INT64_ZERO}}], + msg="$searchMeta should return a lower-bound-zero count on an indexed empty collection " + "when count.type is lowerBound", + ), +] + + +@pytest.fixture(scope="module") +def no_index_collection(engine_client, worker_id) -> Iterator[Collection]: + """Populated collection with no search index.""" + with build_collection( + engine_client, + worker_id, + f"{__name__}::no_index_collection", + "searchmeta_no_index", + SEARCH_DOCS, + None, + ) as coll: + yield coll + + +# Property [No-Index Behavior]: on a populated collection with no search index, +# $searchMeta succeeds and returns a total-zero count that ignores the requested +# count.type, and a facet collector omits the facet field. +SEARCHMETA_NO_INDEX_TESTS: list[CollectionFixtureTestCase] = [ + CollectionFixtureTestCase( + "no_index_default", + collection_fixture="no_index_collection", + pipeline=[{"$searchMeta": {"text": {"query": "quick", "path": "title"}}}], + expected=[{"count": {"total": INT64_ZERO}}], + msg="$searchMeta should return a total-zero count without error on a populated " + "collection that has no search index", + ), + CollectionFixtureTestCase( + "no_index_lower_bound", + collection_fixture="no_index_collection", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "count": {"type": "lowerBound"}, + } + } + ], + expected=[{"count": {"total": INT64_ZERO}}], + msg="$searchMeta should ignore a lowerBound count.type and still key the no-index " + "count as total", + ), + CollectionFixtureTestCase( + "no_index_facet_omits_facet", + collection_fixture="no_index_collection", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": {"nf": {"type": "number", "path": "n", "boundaries": [0, 25]}} + } + } + } + ], + expected=[{"count": {"total": INT64_ZERO}}], + msg="$searchMeta should omit the facet field and return a total-zero count for a facet " + "collector on a collection that has no search index", + ), +] + + +@pytest.fixture(scope="module") +def nonexistent_collection(engine_client, worker_id) -> Iterator[Collection]: + """Handle to a collection that is never created.""" + with build_collection( + engine_client, + worker_id, + f"{__name__}::nonexistent_collection", + "searchmeta_nonexistent", + None, + None, + ) as coll: + yield coll + + +# Property [Nonexistent Collection]: on a nonexistent collection, $searchMeta +# returns an empty result with no metadata document, for both an operator and a +# facet collector. +SEARCHMETA_NONEXISTENT_COLLECTION_TESTS: list[CollectionFixtureTestCase] = [ + CollectionFixtureTestCase( + "nonexistent_operator", + collection_fixture="nonexistent_collection", + pipeline=[{"$searchMeta": {"text": {"query": "quick", "path": "title"}}}], + expected=[], + msg="$searchMeta should return an empty result with no metadata document for an " + "operator on a nonexistent collection", + ), + CollectionFixtureTestCase( + "nonexistent_facet", + collection_fixture="nonexistent_collection", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": {"nf": {"type": "number", "path": "n", "boundaries": [0, 25]}} + } + } + } + ], + expected=[], + msg="$searchMeta should return an empty result with no metadata document for a facet " + "collector on a nonexistent collection", + ), +] + +# All collection-state cases share one execution path; the state is carried as +# data via the fixture each case names. +SEARCHMETA_COLLECTION_STATE_TESTS: list[CollectionFixtureTestCase] = ( + SEARCHMETA_EMPTY_COLLECTION_TESTS + + SEARCHMETA_NO_INDEX_TESTS + + SEARCHMETA_NONEXISTENT_COLLECTION_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCHMETA_COLLECTION_STATE_TESTS)) +def test_searchMeta_collection_state(engine_client, request, test_case: CollectionFixtureTestCase): + """Test $searchMeta across empty, no-index, and nonexistent collection states.""" + collection = request.getfixturevalue(test_case.collection_fixture) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_facets.py b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_facets.py new file mode 100644 index 000000000..a47264a7c --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_facets.py @@ -0,0 +1,447 @@ +"""Tests for $searchMeta number-facet bucketing result behavior.""" + +from __future__ import annotations + +from collections.abc import Iterator +from datetime import datetime, timezone + +import pytest +from bson import Int64 +from pymongo.collection import Collection + +from documentdb_tests.compatibility.tests.core.operator.stages.searchMeta.utils.searchMeta_common import ( # noqa: E501 + CollectionFixtureTestCase, + build_collection, +) +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.test_constants import INT64_ZERO + +pytestmark = pytest.mark.requires(search=True) + + +# Dates spanning two query buckets: ids 1-3 fall in the first interval and ids +# 4-5 in the second, so the asserted bucket counts (3 and 2) are observable. +_DATE_FACET_DOCS = [ + {"_id": 1, "d": datetime(2024, 1, 15, tzinfo=timezone.utc)}, + {"_id": 2, "d": datetime(2024, 2, 15, tzinfo=timezone.utc)}, + {"_id": 3, "d": datetime(2024, 3, 15, tzinfo=timezone.utc)}, + {"_id": 4, "d": datetime(2024, 6, 15, tzinfo=timezone.utc)}, + {"_id": 5, "d": datetime(2024, 9, 15, tzinfo=timezone.utc)}, +] + + +@pytest.fixture(scope="module") +def date_facet_collection(engine_client, worker_id) -> Iterator[Collection]: + """Indexed collection with an explicit date mapping for date faceting. + + The faceted field carries an explicit ``date`` mapping so the index types it + as a date rather than relying on dynamic mapping. + """ + with build_collection( + engine_client, + worker_id, + f"{__name__}::date_facet_collection", + "searchmeta_date_facet", + _DATE_FACET_DOCS, + [ + { + "name": "default", + "definition": {"mappings": {"dynamic": True, "fields": {"d": [{"type": "date"}]}}}, + } + ], + ) as coll: + yield coll + + +# Property [Facet Collector Envelope]: a facet collector returns a combined +# count sub-document and per-name buckets array; the count defaults to lowerBound +# and the embedded operator is optional (omitting it matches all documents). +SEARCHMETA_FACET_ENVELOPE_TESTS: list[CollectionFixtureTestCase] = [ + CollectionFixtureTestCase( + "facet_envelope_with_operator", + collection_fixture="search_collection", + pipeline=[ + { + "$searchMeta": { + "facet": { + "operator": {"text": {"query": "quick", "path": "title"}}, + "facets": { + "nf": {"type": "number", "path": "n", "boundaries": [0, 5, 10, 25]} + }, + } + } + } + ], + expected=[ + { + "count": {"lowerBound": Int64(3)}, + "facet": { + "nf": { + "buckets": [ + {"_id": 0, "count": Int64(1)}, + {"_id": 5, "count": Int64(1)}, + {"_id": 10, "count": Int64(1)}, + ] + } + }, + } + ], + msg="$searchMeta facet collector should return a combined count and facet buckets " + "envelope for an embedded operator", + ), + CollectionFixtureTestCase( + "facet_envelope_match_all", + collection_fixture="search_collection", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": {"nf": {"type": "number", "path": "n", "boundaries": [0, 10, 25]}} + } + } + } + ], + expected=[ + { + "count": {"lowerBound": Int64(5)}, + "facet": { + "nf": { + "buckets": [ + {"_id": 0, "count": Int64(2)}, + {"_id": 10, "count": Int64(3)}, + ] + } + }, + } + ], + msg="$searchMeta facet collector should match all documents and default to a " + "lower-bound count when the operator is omitted", + ), +] + +# Property [Facet Number Bucket Boundaries]: each number-facet bucket _id is the +# lower boundary of its range, and float boundaries are preserved as double _id +# values. +SEARCHMETA_FACET_BOUNDARY_TESTS: list[CollectionFixtureTestCase] = [ + CollectionFixtureTestCase( + "facet_number_float_boundaries", + collection_fixture="search_collection", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": { + "nf": { + "type": "number", + "path": "n", + "boundaries": [0.5, 10.5, 25.5], + } + } + } + } + } + ], + expected=[ + { + "count": {"lowerBound": Int64(5)}, + "facet": { + "nf": { + "buckets": [ + {"_id": 0.5, "count": Int64(3)}, + {"_id": 10.5, "count": Int64(2)}, + ] + } + }, + } + ], + msg="$searchMeta number facet should preserve float boundaries as double bucket _ids", + ), +] + +# Property [Facet Default Overflow Bucket]: a default overflow bucket is emitted +# only when default is set, is always emitted when set (including with zero +# overflow), and its string _id never collides with a numeric boundary _id. +SEARCHMETA_FACET_DEFAULT_TESTS: list[CollectionFixtureTestCase] = [ + CollectionFixtureTestCase( + "facet_default_omitted_drops_overflow", + collection_fixture="search_collection", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": {"nf": {"type": "number", "path": "n", "boundaries": [0, 5]}} + } + } + } + ], + expected=[ + { + "count": {"lowerBound": Int64(5)}, + "facet": {"nf": {"buckets": [{"_id": 0, "count": Int64(1)}]}}, + } + ], + msg="$searchMeta number facet should drop out-of-range values when no default is set", + ), + CollectionFixtureTestCase( + "facet_default_emits_overflow", + collection_fixture="search_collection", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": { + "nf": { + "type": "number", + "path": "n", + "boundaries": [0, 5, 10], + "default": "over", + } + } + } + } + } + ], + expected=[ + { + "count": {"lowerBound": Int64(5)}, + "facet": { + "nf": { + "buckets": [ + {"_id": 0, "count": Int64(1)}, + {"_id": 5, "count": Int64(1)}, + {"_id": "over", "count": Int64(3)}, + ] + } + }, + } + ], + msg="$searchMeta number facet should collect out-of-range values into the default " + "overflow bucket when default is set", + ), + CollectionFixtureTestCase( + "facet_default_zero_overflow", + collection_fixture="search_collection", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": { + "nf": { + "type": "number", + "path": "n", + "boundaries": [0, 100], + "default": "over", + } + } + } + } + } + ], + expected=[ + { + "count": {"lowerBound": Int64(5)}, + "facet": { + "nf": { + "buckets": [ + {"_id": 0, "count": Int64(5)}, + {"_id": "over", "count": INT64_ZERO}, + ] + } + }, + } + ], + msg="$searchMeta number facet should emit the default overflow bucket with a zero " + "count when no values overflow", + ), + CollectionFixtureTestCase( + "facet_default_string_id_no_collision", + collection_fixture="search_collection", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": { + "nf": { + "type": "number", + "path": "n", + "boundaries": [0, 5], + "default": "0", + } + } + } + } + } + ], + expected=[ + { + "count": {"lowerBound": Int64(5)}, + "facet": { + "nf": { + "buckets": [ + {"_id": 0, "count": Int64(1)}, + {"_id": "0", "count": Int64(4)}, + ] + } + }, + } + ], + msg="$searchMeta number facet should keep a string default _id distinct from a " + "numeric boundary _id of the same digits", + ), +] + +# Property [Facet Zero-Match Buckets]: a zero-match facet query over an indexed +# collection returns the bucket structure with zero counts. +SEARCHMETA_FACET_ZERO_MATCH_TESTS: list[CollectionFixtureTestCase] = [ + CollectionFixtureTestCase( + "facet_zero_match", + collection_fixture="search_collection", + pipeline=[ + { + "$searchMeta": { + "facet": { + "operator": {"text": {"query": "nonexistentxyz", "path": "title"}}, + "facets": {"nf": {"type": "number", "path": "n", "boundaries": [0, 2, 25]}}, + } + } + } + ], + expected=[ + { + "count": {"lowerBound": INT64_ZERO}, + "facet": { + "nf": { + "buckets": [ + {"_id": 0, "count": INT64_ZERO}, + {"_id": 2, "count": INT64_ZERO}, + ] + } + }, + } + ], + msg="$searchMeta facet collector should return zero-count buckets for a zero-match " + "query", + ), +] + +# Property [Facet Date Bucket Boundaries]: each date-facet bucket _id is its +# range's lower-boundary datetime, and datetimes are bucketed like number facets. +SEARCHMETA_DATE_FACET_TESTS: list[CollectionFixtureTestCase] = [ + CollectionFixtureTestCase( + "date_facet_boundaries", + collection_fixture="date_facet_collection", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": { + "df": { + "type": "date", + "path": "d", + "boundaries": [ + datetime(2024, 1, 1, tzinfo=timezone.utc), + datetime(2024, 4, 1, tzinfo=timezone.utc), + datetime(2024, 12, 1, tzinfo=timezone.utc), + ], + } + } + } + } + } + ], + expected=[ + { + "count": {"lowerBound": Int64(5)}, + "facet": { + "df": { + "buckets": [ + {"_id": datetime(2024, 1, 1, tzinfo=timezone.utc), "count": Int64(3)}, + {"_id": datetime(2024, 4, 1, tzinfo=timezone.utc), "count": Int64(2)}, + ] + } + }, + } + ], + msg="$searchMeta date facet should bucket datetimes by their lower boundary and echo " + "each bucket _id as the boundary datetime", + ), +] + +# Property [Facet Date Default Overflow Bucket]: a date facet collects datetimes +# outside its boundary ranges into the default overflow bucket when default is +# set, mirroring the number-facet default behavior. +SEARCHMETA_DATE_FACET_DEFAULT_TESTS: list[CollectionFixtureTestCase] = [ + CollectionFixtureTestCase( + "date_facet_default_overflow", + collection_fixture="date_facet_collection", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": { + "df": { + "type": "date", + "path": "d", + "boundaries": [ + datetime(2024, 1, 1, tzinfo=timezone.utc), + datetime(2024, 4, 1, tzinfo=timezone.utc), + ], + "default": "over", + } + } + } + } + } + ], + expected=[ + { + "count": {"lowerBound": Int64(5)}, + "facet": { + "df": { + "buckets": [ + {"_id": datetime(2024, 1, 1, tzinfo=timezone.utc), "count": Int64(3)}, + {"_id": "over", "count": Int64(2)}, + ] + } + }, + } + ], + msg="$searchMeta date facet should collect out-of-range datetimes into the default " + "overflow bucket when default is set", + ), +] + +# Number facets run against the standard search collection; date faceting needs +# an index with an explicit date mapping, so its case names a different fixture. +# Both share one execution path, with the collection carried as data. +SEARCHMETA_FACET_RESULT_TESTS: list[CollectionFixtureTestCase] = ( + SEARCHMETA_FACET_ENVELOPE_TESTS + + SEARCHMETA_FACET_BOUNDARY_TESTS + + SEARCHMETA_FACET_DEFAULT_TESTS + + SEARCHMETA_FACET_ZERO_MATCH_TESTS + + SEARCHMETA_DATE_FACET_TESTS + + SEARCHMETA_DATE_FACET_DEFAULT_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCHMETA_FACET_RESULT_TESTS)) +def test_searchMeta_facets(engine_client, request, test_case: CollectionFixtureTestCase): + """Test $searchMeta number- and date-facet bucket result behavior.""" + collection = request.getfixturevalue(test_case.collection_fixture) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_facets_advanced.py b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_facets_advanced.py new file mode 100644 index 000000000..f5650afd2 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_facets_advanced.py @@ -0,0 +1,234 @@ +"""Tests for $searchMeta facet count modifiers, key echo, and multiple facets.""" + +from __future__ import annotations + +import pytest +from bson import Int64 + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = pytest.mark.requires(search=True) + + +# Property [Facet Stage Count Modifier]: a stage-level count modifier changes +# only the count sub-document flavor and leaves the facet result unchanged. +SEARCHMETA_FACET_COUNT_MODIFIER_TESTS: list[StageTestCase] = [ + StageTestCase( + "facet_count_total", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": {"nf": {"type": "number", "path": "n", "boundaries": [0, 25]}} + }, + "count": {"type": "total"}, + } + } + ], + expected=[ + { + "count": {"total": Int64(5)}, + "facet": {"nf": {"buckets": [{"_id": 0, "count": Int64(5)}]}}, + } + ], + msg="$searchMeta should apply a stage-level total count modifier on top of the facet " + "result without changing the buckets", + ), + StageTestCase( + "facet_count_lower_bound", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": {"nf": {"type": "number", "path": "n", "boundaries": [0, 25]}} + }, + "count": {"type": "lowerBound"}, + } + } + ], + expected=[ + { + "count": {"lowerBound": Int64(5)}, + "facet": {"nf": {"buckets": [{"_id": 0, "count": Int64(5)}]}}, + } + ], + msg="$searchMeta should apply a stage-level lower-bound count modifier on top of the " + "facet result without changing the buckets", + ), +] + +# Property [Facet Key Echo]: facet map keys are not field-path validated and are +# echoed back verbatim as result keys. +SEARCHMETA_FACET_KEY_TESTS: list[StageTestCase] = [ + StageTestCase( + f"facet_key_{suffix}", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": {key: {"type": "number", "path": "n", "boundaries": [0, 25]}} + } + } + } + ], + expected=[ + { + "count": {"lowerBound": Int64(5)}, + "facet": {key: {"buckets": [{"_id": 0, "count": Int64(5)}]}}, + } + ], + msg=f"$searchMeta should echo a {suffix} facet key verbatim without field-path " + "validation", + ) + for key, suffix in [ + ("", "empty"), + ("$x", "dollar_prefixed"), + ("a.b", "dotted"), + ] +] + +# Property [Multiple Facets in One Collector]: a collector with multiple named +# facets computes each independently under its own key while sharing one count +# sub-document; a default bucket on one facet does not affect a sibling without +# one. +SEARCHMETA_MULTI_FACET_TESTS: list[StageTestCase] = [ + StageTestCase( + "multi_facet_different_paths", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": { + "nf": {"type": "number", "path": "n", "boundaries": [0, 10, 25]}, + "idf": {"type": "number", "path": "_id", "boundaries": [0, 3, 10]}, + } + } + } + } + ], + expected=[ + { + "count": {"lowerBound": Int64(5)}, + "facet": { + "nf": { + "buckets": [ + {"_id": 0, "count": Int64(2)}, + {"_id": 10, "count": Int64(3)}, + ] + }, + "idf": { + "buckets": [ + {"_id": 0, "count": Int64(2)}, + {"_id": 3, "count": Int64(3)}, + ] + }, + }, + } + ], + msg="$searchMeta should compute sibling facets over different paths independently " + "under a single shared count", + ), + StageTestCase( + "multi_facet_same_path_different_boundaries", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": { + "nf1": {"type": "number", "path": "n", "boundaries": [0, 10, 25]}, + "nf2": {"type": "number", "path": "n", "boundaries": [0, 5, 25]}, + } + } + } + } + ], + expected=[ + { + "count": {"lowerBound": Int64(5)}, + "facet": { + "nf1": { + "buckets": [ + {"_id": 0, "count": Int64(2)}, + {"_id": 10, "count": Int64(3)}, + ] + }, + "nf2": { + "buckets": [ + {"_id": 0, "count": Int64(1)}, + {"_id": 5, "count": Int64(4)}, + ] + }, + }, + } + ], + msg="$searchMeta should compute sibling facets over the same path with different " + "boundaries independently", + ), + StageTestCase( + "multi_facet_independent_default", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": { + "withd": { + "type": "number", + "path": "n", + "boundaries": [0, 5], + "default": "over", + }, + "nod": {"type": "number", "path": "n", "boundaries": [0, 5]}, + } + } + } + } + ], + expected=[ + { + "count": {"lowerBound": Int64(5)}, + "facet": { + "withd": { + "buckets": [ + {"_id": 0, "count": Int64(1)}, + {"_id": "over", "count": Int64(4)}, + ] + }, + "nod": {"buckets": [{"_id": 0, "count": Int64(1)}]}, + }, + } + ], + msg="$searchMeta should keep a default overflow bucket independent per facet so a " + "sibling without default drops its overflow", + ), +] + +SEARCHMETA_FACET_ADVANCED_TESTS: list[StageTestCase] = ( + SEARCHMETA_FACET_COUNT_MODIFIER_TESTS + + SEARCHMETA_FACET_KEY_TESTS + + SEARCHMETA_MULTI_FACET_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCHMETA_FACET_ADVANCED_TESTS)) +def test_searchMeta_facets_advanced(search_collection, test_case: StageTestCase): + """Test $searchMeta facet count modifiers, key echo, and multiple facets.""" + result = execute_command( + search_collection, + { + "aggregate": search_collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_index.py b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_index.py new file mode 100644 index 000000000..951babda3 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_index.py @@ -0,0 +1,250 @@ +"""Tests for $searchMeta index selection and query modifier behavior.""" + +from __future__ import annotations + +import pytest +from bson import Int64 + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, +) +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.test_constants import INT64_ZERO + +pytestmark = pytest.mark.requires(search=True) + + +# Property [Index Selection and Defaulting]: the index field names the search +# index to use, and omitting it or passing null selects the index named +# "default". +SEARCHMETA_INDEX_DEFAULTING_TESTS: list[StageTestCase] = [ + StageTestCase( + "index_explicit_default", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "index": "default", + } + } + ], + expected=[{"count": {"lowerBound": Int64(3)}}], + msg="$searchMeta should select the default index when index is the literal " + '"default", matching the omitted-index result', + ), + StageTestCase( + "index_named_non_default", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "index": "alt_idx", + } + } + ], + expected=[{"count": {"lowerBound": Int64(3)}}], + msg="$searchMeta should select a non-default index when its name matches", + ), + StageTestCase( + "index_null_default", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "index": None, + } + } + ], + expected=[{"count": {"lowerBound": Int64(3)}}], + msg="$searchMeta should treat a null index as field-absent and use the default index", + ), +] + +# Property [Index Nonexistent Silent Miss]: a well-formed but nonexistent index +# name returns a total-zero count without error; dollar-prefixed strings are +# taken as literal names, and a facet collector omits the facet field on a miss. +SEARCHMETA_INDEX_MISS_TESTS: list[StageTestCase] = [ + StageTestCase( + "index_nonexistent_silent_miss", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "index": "nope", + } + } + ], + expected=[{"count": {"total": INT64_ZERO}}], + msg="$searchMeta should return a total-zero count without error for a " + "nonexistent index name", + ), + StageTestCase( + "index_dollar_field_literal", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "index": "$title", + } + } + ], + expected=[{"count": {"total": INT64_ZERO}}], + msg="$searchMeta should treat a dollar-prefixed index as a literal nonexistent " + "name rather than a field path", + ), + StageTestCase( + "index_double_dollar_literal", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "index": "$$NOW", + } + } + ], + expected=[{"count": {"total": INT64_ZERO}}], + msg="$searchMeta should treat a double-dollar index as a literal nonexistent " + "name rather than a system variable", + ), + StageTestCase( + "index_facet_nonexistent_omits_facet", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": {"nf": {"type": "number", "path": "n", "boundaries": [0, 25]}} + }, + "index": "nope", + } + } + ], + expected=[{"count": {"total": INT64_ZERO}}], + msg="$searchMeta should omit the facet field and return a total-zero count when " + "a facet collector targets a nonexistent index", + ), +] + +# Property [Index Name Matching and Character Handling]: index name matching is +# exact, case-sensitive, and not whitespace-trimmed; control characters, null +# bytes, Unicode, and long names are valid strings that miss silently with a +# total-zero count. +SEARCHMETA_INDEX_NAME_MATCHING_TESTS: list[StageTestCase] = [ + StageTestCase( + f"index_name_{suffix}", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "index": name, + } + } + ], + expected=[{"count": {"total": INT64_ZERO}}], + msg=f"$searchMeta should treat a {suffix} index name as a nonexistent name and " + "miss silently", + ) + for name, suffix in [ + ("Default", "capitalized_default"), + ("default ", "trailing_space_default"), + (" default", "leading_space_default"), + ("def ault", "embedded_space_default"), + ("a\x00b", "embedded_null_byte"), + ("\x00", "single_null_byte"), + # Control characters U+0001, U+0002, U+001F. + ("\x01\x02\x1f", "control_characters"), + ("\t", "tab"), + ("caf\u00e9_\u7d22\u5f15_\U0001f50d", "unicode"), + ("x" * 10_000, "long_name"), + ] +] + +# Property [Modifier Coexistence]: the count and index modifiers coexist with a +# search operator in the same spec without being mistaken for operators. +SEARCHMETA_MODIFIER_COEXISTENCE_TESTS: list[StageTestCase] = [ + StageTestCase( + "coexist_operator_count_index", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "count": {"type": "total"}, + "index": "default", + } + } + ], + expected=[{"count": {"total": Int64(3)}}], + msg="$searchMeta should accept count and index modifiers alongside a search operator " + "without mistaking them for operators", + ), +] + +# Property [Zero-Match Count]: a query matching no documents on an indexed +# collection returns a zero count respecting the requested count.type, defaulting +# to lowerBound. +SEARCHMETA_ZERO_MATCH_TESTS: list[StageTestCase] = [ + StageTestCase( + "zero_match_default", + pipeline=[{"$searchMeta": {"text": {"query": "nonexistentxyz", "path": "title"}}}], + expected=[{"count": {"lowerBound": INT64_ZERO}}], + msg="$searchMeta should default to a lower-bound zero count for a zero-match query on " + "an indexed collection", + ), + StageTestCase( + "zero_match_total", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "nonexistentxyz", "path": "title"}, + "count": {"type": "total"}, + } + } + ], + expected=[{"count": {"total": INT64_ZERO}}], + msg="$searchMeta should return a total-zero count for a zero-match query when count.type " + "is total", + ), + StageTestCase( + "zero_match_lower_bound", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "nonexistentxyz", "path": "title"}, + "count": {"type": "lowerBound"}, + } + } + ], + expected=[{"count": {"lowerBound": INT64_ZERO}}], + msg="$searchMeta should return a lower-bound-zero count for a zero-match query when " + "count.type is lowerBound", + ), +] + +SEARCHMETA_INDEX_TESTS: list[StageTestCase] = ( + SEARCHMETA_INDEX_DEFAULTING_TESTS + + SEARCHMETA_INDEX_MISS_TESTS + + SEARCHMETA_INDEX_NAME_MATCHING_TESTS + + SEARCHMETA_MODIFIER_COEXISTENCE_TESTS + + SEARCHMETA_ZERO_MATCH_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCHMETA_INDEX_TESTS)) +def test_searchMeta_index(search_collection, test_case: StageTestCase): + """Test $searchMeta index selection and query modifier behavior.""" + result = execute_command( + search_collection, + { + "aggregate": search_collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_metadata.py b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_metadata.py new file mode 100644 index 000000000..1d3047dd3 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_metadata.py @@ -0,0 +1,288 @@ +"""Tests for $searchMeta metadata and count result semantics.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + EXPRESSION_NOT_OBJECT_ERROR, + FAILED_TO_PARSE_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Gte, Lte +from documentdb_tests.framework.test_constants import DECIMAL128_ZERO, INT32_MAX + +pytestmark = pytest.mark.requires(search=True) + + +# Property [Count Result Shape and Defaults]: count.type selects the result +# flavor (an exact {count:{total:n}} for total, a {count:{lowerBound:n}} for +# lowerBound), and an empty, null, or type-less count defaults to a +# lower-bound count. +SEARCHMETA_COUNT_SHAPE_TESTS: list[StageTestCase] = [ + StageTestCase( + "count_type_total", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "count": {"type": "total"}, + } + } + ], + expected=[{"count": {"total": Int64(3)}}], + msg="$searchMeta count.type total should return an exact total count", + ), + StageTestCase( + "count_type_lower_bound", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "count": {"type": "lowerBound"}, + } + } + ], + expected=[{"count": {"lowerBound": Int64(3)}}], + msg="$searchMeta count.type lowerBound should return a lower-bound count", + ), + StageTestCase( + "count_default_empty", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "count": {}, + } + } + ], + expected=[{"count": {"lowerBound": Int64(3)}}], + msg="$searchMeta should default to a lower-bound count when count is an empty document", + ), + StageTestCase( + "count_default_null", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "count": None, + } + } + ], + expected=[{"count": {"lowerBound": Int64(3)}}], + msg="$searchMeta should default to a lower-bound count when count is null", + ), + StageTestCase( + "count_default_type_null", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "count": {"type": None}, + } + } + ], + expected=[{"count": {"lowerBound": Int64(3)}}], + msg="$searchMeta should default to a lower-bound count when count.type is null", + ), + StageTestCase( + "count_threshold_no_type", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "count": {"threshold": 10}, + } + } + ], + expected=[{"count": {"lowerBound": Int64(3)}}], + msg="$searchMeta should default to a lower-bound count when a threshold has no type", + ), +] + +# Property [Count Threshold Exactness]: count.type total returns an exact count +# regardless of threshold, and count.type lowerBound returns the exact match +# count whenever the threshold is at least the match count. +SEARCHMETA_THRESHOLD_EXACT_TESTS: list[StageTestCase] = [ + StageTestCase( + "threshold_total_ignores_below_match", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "count": {"type": "total", "threshold": 1}, + } + } + ], + expected=[{"count": {"total": Int64(3)}}], + msg="$searchMeta count.type total should ignore a threshold below the match count and " + "stay exact", + ), + StageTestCase( + "threshold_lower_bound_exact_when_equal", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "count": {"type": "lowerBound", "threshold": 3}, + } + } + ], + expected=[{"count": {"lowerBound": Int64(3)}}], + msg="$searchMeta count.type lowerBound should be exact when threshold equals the " + "match count", + ), +] + +# Property [Count Threshold Type Acceptance]: count.threshold accepts any +# non-negative integer-valued number (int32, in-range int64, whole-number +# double), including the boundaries 0 and int32 max, without error. +SEARCHMETA_THRESHOLD_TYPE_TESTS: list[StageTestCase] = [ + StageTestCase( + "threshold_type_int32_zero", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "count": {"type": "lowerBound", "threshold": 0}, + } + } + ], + expected={"count": {"lowerBound": [Gte(0), Lte(3)]}}, + msg="$searchMeta should accept a zero threshold and return a count within the bound", + ), + StageTestCase( + "threshold_type_int32_max", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "count": {"type": "lowerBound", "threshold": INT32_MAX}, + } + } + ], + expected=[{"count": {"lowerBound": Int64(3)}}], + msg="$searchMeta should accept an int32-max threshold boundary", + ), + StageTestCase( + "threshold_type_int64", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "count": {"type": "lowerBound", "threshold": Int64(5)}, + } + } + ], + expected=[{"count": {"lowerBound": Int64(3)}}], + msg="$searchMeta should accept an in-range int64 threshold", + ), + StageTestCase( + "threshold_type_double_whole", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "count": {"type": "lowerBound", "threshold": 5.0}, + } + } + ], + expected=[{"count": {"lowerBound": Int64(3)}}], + msg="$searchMeta should accept a whole-number double threshold", + ), +] + +SEARCHMETA_RESULT_TESTS: list[StageTestCase] = ( + SEARCHMETA_COUNT_SHAPE_TESTS + + SEARCHMETA_THRESHOLD_EXACT_TESTS + + SEARCHMETA_THRESHOLD_TYPE_TESTS +) + +# Property [Stage Value Scalar Type Error]: a scalar or null stage value is +# rejected at parse time as not an object. +SEARCHMETA_STAGE_VALUE_SCALAR_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"stage_value_scalar_{tid}", + pipeline=[{"$searchMeta": val}], + error_code=EXPRESSION_NOT_OBJECT_ERROR, + msg=f"$searchMeta should reject a {tid} stage value as a non-object", + ) + for tid, val in [ + ("string", "x"), + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", DECIMAL128_ZERO), + ("bool", True), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("null", None), + ] +] + +# Property [Stage Value Array Type Error]: an array stage value, including an +# empty array, is rejected at parse time and distinguished from a scalar. +SEARCHMETA_STAGE_VALUE_ARRAY_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "stage_value_array_empty", + pipeline=[{"$searchMeta": []}], + error_code=FAILED_TO_PARSE_ERROR, + msg="$searchMeta should reject an empty array stage value as a non-object", + ), + StageTestCase( + "stage_value_array_non_empty", + pipeline=[{"$searchMeta": [{"text": {"query": "quick", "path": "title"}}]}], + error_code=FAILED_TO_PARSE_ERROR, + msg="$searchMeta should reject a non-empty array stage value as a non-object", + ), +] + +SEARCHMETA_STAGE_VALUE_ERROR_TESTS: list[StageTestCase] = ( + SEARCHMETA_STAGE_VALUE_SCALAR_ERROR_TESTS + SEARCHMETA_STAGE_VALUE_ARRAY_ERROR_TESTS +) + +# Result/count semantics and stage-value type errors share one execution path. +SEARCHMETA_TESTS: list[StageTestCase] = SEARCHMETA_RESULT_TESTS + SEARCHMETA_STAGE_VALUE_ERROR_TESTS + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCHMETA_TESTS)) +def test_searchMeta_metadata(search_collection, test_case: StageTestCase): + """Test $searchMeta metadata, count result semantics, and stage-value errors.""" + result = execute_command( + search_collection, + { + "aggregate": search_collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_operators.py b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_operators.py new file mode 100644 index 000000000..aedea2e42 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_operators.py @@ -0,0 +1,384 @@ +"""Tests for $searchMeta recognized-operator compatibility (acceptance matrix).""" + +from __future__ import annotations + +from collections.abc import Iterator + +import pytest +from bson import Int64 +from pymongo.collection import Collection + +from documentdb_tests.compatibility.tests.core.operator.stages.searchMeta.utils.searchMeta_common import ( # noqa: E501 + CollectionFixtureTestCase, + build_collection, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import UNKNOWN_ERROR +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import DOUBLE_ZERO + +pytestmark = pytest.mark.requires(search=True) + + +@pytest.fixture(scope="module") +def autocomplete_collection(engine_client, worker_id) -> Iterator[Collection]: + """Collection whose title field is statically indexed as an autocomplete type.""" + with build_collection( + engine_client, + worker_id, + f"{__name__}::autocomplete_collection", + "searchmeta_autocomplete", + [ + {"_id": 1, "title": "quick brown fox"}, + {"_id": 2, "title": "quiet night"}, + {"_id": 3, "title": "quick red fox"}, + {"_id": 4, "title": "lazy dog"}, + ], + [ + { + "name": "default", + "definition": { + "mappings": {"dynamic": False, "fields": {"title": {"type": "autocomplete"}}} + }, + } + ], + ) as coll: + yield coll + + +@pytest.fixture(scope="module") +def geo_collection(engine_client, worker_id) -> Iterator[Collection]: + """Collection whose loc field is statically indexed as a geo type with shapes.""" + with build_collection( + engine_client, + worker_id, + f"{__name__}::geo_collection", + "searchmeta_geo", + [ + {"_id": 1, "loc": {"type": "Point", "coordinates": [DOUBLE_ZERO, DOUBLE_ZERO]}}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [10.0, 10.0]}}, + {"_id": 3, "loc": {"type": "Point", "coordinates": [0.1, 0.1]}}, + ], + [ + { + "name": "default", + "definition": { + "mappings": { + "dynamic": False, + "fields": {"loc": {"type": "geo", "indexShapes": True}}, + } + }, + } + ], + ) as coll: + yield coll + + +@pytest.fixture(scope="module") +def embedded_collection(engine_client, worker_id) -> Iterator[Collection]: + """Collection whose items array is statically indexed as embeddedDocuments.""" + with build_collection( + engine_client, + worker_id, + f"{__name__}::embedded_collection", + "searchmeta_embedded", + [ + {"_id": 1, "items": [{"name": "quick fox"}, {"name": "slow dog"}]}, + {"_id": 2, "items": [{"name": "quick cat"}]}, + {"_id": 3, "items": [{"name": "lazy bird"}]}, + ], + [ + { + "name": "default", + "definition": { + "mappings": { + "dynamic": False, + "fields": {"items": {"type": "embeddedDocuments", "dynamic": True}}, + } + }, + } + ], + ) as coll: + yield coll + + +# Property [Operator Acceptance On A Dynamic Mapping]: each recognized search +# operator that a dynamic mapping can serve is accepted and returns its match +# count as the operator-independent {count:{lowerBound:n}} metadata envelope. +SEARCHMETA_DYNAMIC_OPERATOR_TESTS: list[CollectionFixtureTestCase] = [ + CollectionFixtureTestCase( + f"operator_{label}", + collection_fixture="search_collection", + pipeline=[{"$searchMeta": {op: spec}}], + expected=[{"count": {"lowerBound": Int64(count)}}], + msg=f"$searchMeta should accept a {label} operator and return its match count as metadata", + ) + for label, op, spec, count in [ + ("text", "text", {"query": "quick", "path": "title"}, 3), + ("phrase", "phrase", {"query": "brown fox", "path": "title"}, 1), + ( + "wildcard", + "wildcard", + {"query": "quick*", "path": "title", "allowAnalyzedField": True}, + 3, + ), + ("exists", "exists", {"path": "title"}, 5), + ("equals", "equals", {"path": "n", "value": 5}, 1), + ("range", "range", {"path": "n", "gte": 5, "lte": 15}, 3), + ("near", "near", {"path": "n", "origin": 10, "pivot": 5}, 5), + ( + "compound", + "compound", + {"must": [{"text": {"query": "quick", "path": "title"}}]}, + 3, + ), + ("in", "in", {"path": "n", "value": [5, 10]}, 2), + ( + "regex", + "regex", + {"query": "quick.*", "path": "title", "allowAnalyzedField": True}, + 3, + ), + ("queryString", "queryString", {"defaultPath": "title", "query": "quick"}, 3), + ("moreLikeThis", "moreLikeThis", {"like": {"title": "quick brown fox"}}, 4), + ("term", "term", {"query": "quick", "path": "title"}, 3), + ( + "span", + "span", + { + "first": { + "operator": {"term": {"query": "quick", "path": "title"}}, + "endPositionLte": 5, + } + }, + 3, + ), + ] +] + +# Property [Operator Acceptance With A Required Index Type]: operators that +# depend on a specific field index type (autocomplete, geo, embeddedDocuments) +# are accepted and return their match count once the field is indexed for them. +SEARCHMETA_REQUIRED_INDEX_OPERATOR_TESTS: list[CollectionFixtureTestCase] = [ + CollectionFixtureTestCase( + "operator_autocomplete", + collection_fixture="autocomplete_collection", + pipeline=[{"$searchMeta": {"autocomplete": {"query": "qui", "path": "title"}}}], + expected=[{"count": {"lowerBound": Int64(3)}}], + msg="$searchMeta should accept an autocomplete operator against an autocomplete-indexed " + "field and return its match count", + ), + CollectionFixtureTestCase( + "operator_geoWithin", + collection_fixture="geo_collection", + pipeline=[ + { + "$searchMeta": { + "geoWithin": { + "path": "loc", + "circle": { + "center": {"type": "Point", "coordinates": [DOUBLE_ZERO, DOUBLE_ZERO]}, + "radius": 50000, + }, + } + } + } + ], + expected=[{"count": {"lowerBound": Int64(2)}}], + msg="$searchMeta should accept a geoWithin operator against a geo-indexed field and " + "return its match count", + ), + CollectionFixtureTestCase( + "operator_geoShape", + collection_fixture="geo_collection", + pipeline=[ + { + "$searchMeta": { + "geoShape": { + "path": "loc", + "relation": "intersects", + "geometry": { + "type": "Polygon", + "coordinates": [[[-1, -1], [1, -1], [1, 1], [-1, 1], [-1, -1]]], + }, + } + } + } + ], + expected=[{"count": {"lowerBound": Int64(2)}}], + msg="$searchMeta should accept a geoShape operator against a shape-indexed geo field and " + "return its match count", + ), + CollectionFixtureTestCase( + "operator_embeddedDocument", + collection_fixture="embedded_collection", + pipeline=[ + { + "$searchMeta": { + "embeddedDocument": { + "path": "items", + "operator": {"text": {"query": "quick", "path": "items.name"}}, + } + } + } + ], + expected=[{"count": {"lowerBound": Int64(2)}}], + msg="$searchMeta should accept an embeddedDocument operator against an " + "embeddedDocuments-indexed field and return its match count", + ), +] + +# Property [Operators Requiring An Absent Index Feature]: operators that depend +# on a specific index type or feature are recognized but rejected at execution +# when that index type or feature is not present on the collection. +SEARCHMETA_UNSUPPORTED_OPERATOR_TESTS: list[CollectionFixtureTestCase] = [ + CollectionFixtureTestCase( + "operator_autocomplete_no_index", + collection_fixture="search_collection", + pipeline=[{"$searchMeta": {"autocomplete": {"query": "qui", "path": "title"}}}], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject an autocomplete operator when the path is not indexed as " + "an autocomplete field", + ), + CollectionFixtureTestCase( + "operator_geoWithin_no_index", + collection_fixture="search_collection", + pipeline=[ + { + "$searchMeta": { + "geoWithin": { + "path": "loc", + "circle": { + "center": {"type": "Point", "coordinates": [DOUBLE_ZERO, DOUBLE_ZERO]}, + "radius": 50000, + }, + } + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject a geoWithin operator when the path is not indexed as a geo " + "field", + ), + CollectionFixtureTestCase( + "operator_geoShape_no_index", + collection_fixture="search_collection", + pipeline=[ + { + "$searchMeta": { + "geoShape": { + "path": "loc", + "relation": "intersects", + "geometry": { + "type": "Polygon", + "coordinates": [[[-1, -1], [1, -1], [1, 1], [-1, 1], [-1, -1]]], + }, + } + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject a geoShape operator when the path is not indexed as a " + "shape-enabled geo field", + ), + CollectionFixtureTestCase( + "operator_embeddedDocument_no_index", + collection_fixture="search_collection", + pipeline=[ + { + "$searchMeta": { + "embeddedDocument": { + "path": "items", + "operator": {"text": {"query": "quick", "path": "items.name"}}, + } + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject an embeddedDocument operator when the path is not indexed " + "as an embeddedDocuments field", + ), + CollectionFixtureTestCase( + "operator_hasAncestor", + collection_fixture="search_collection", + pipeline=[{"$searchMeta": {"hasAncestor": {"ancestorPath": "title"}}}], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject a hasAncestor operator without an indexed document " + "hierarchy", + ), + CollectionFixtureTestCase( + "operator_hasRoot", + collection_fixture="search_collection", + pipeline=[ + { + "$searchMeta": { + "hasRoot": {"operator": {"text": {"query": "quick", "path": "title"}}} + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject a hasRoot operator without an indexed document hierarchy", + ), + CollectionFixtureTestCase( + "operator_knnBeta", + collection_fixture="search_collection", + pipeline=[{"$searchMeta": {"knnBeta": {"path": "title", "vector": [0.1, 0.2], "k": 2}}}], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject a knnBeta operator without a vector-indexed field", + ), + CollectionFixtureTestCase( + "operator_vectorSearch", + collection_fixture="search_collection", + pipeline=[ + { + "$searchMeta": { + "vectorSearch": { + "path": "title", + "queryVector": [0.1, 0.2], + "numCandidates": 10, + "limit": 5, + } + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject a vectorSearch operator without a vector-indexed field", + ), + CollectionFixtureTestCase( + "operator_search", + collection_fixture="search_collection", + pipeline=[{"$searchMeta": {"search": {"text": {"query": "quick", "path": "title"}}}}], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject a nested search operator", + ), +] + +SEARCHMETA_OPERATOR_TESTS: list[CollectionFixtureTestCase] = ( + SEARCHMETA_DYNAMIC_OPERATOR_TESTS + + SEARCHMETA_REQUIRED_INDEX_OPERATOR_TESTS + + SEARCHMETA_UNSUPPORTED_OPERATOR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCHMETA_OPERATOR_TESTS)) +def test_searchMeta_operator_compatibility( + engine_client, request, test_case: CollectionFixtureTestCase +): + """Test $searchMeta acceptance of every recognized search operator.""" + collection = request.getfixturevalue(test_case.collection_fixture) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_options.py b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_options.py new file mode 100644 index 000000000..e53482e0c --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_options.py @@ -0,0 +1,90 @@ +"""Tests for the $searchMeta concurrent stage option.""" + +from __future__ import annotations + +import datetime + +import pytest +from bson import Binary, Code, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import UNKNOWN_ERROR +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import DECIMAL128_ONE_AND_HALF + +pytestmark = pytest.mark.requires(search=True) + + +# Property [Concurrent Option]: the concurrent option is a recognized boolean +# stage option, so both true and false are accepted with no coercion and the +# metadata count is still returned. +SEARCHMETA_CONCURRENT_OPTION_TESTS: list[StageTestCase] = [ + StageTestCase( + f"concurrent_{label}", + pipeline=[ + {"$searchMeta": {"text": {"query": "quick", "path": "title"}, "concurrent": val}} + ], + expected=[{"count": {"lowerBound": Int64(3)}}], + msg=f"$searchMeta should accept a {label} concurrent option and still return its count", + ) + for label, val in [("true", True), ("false", False)] +] + +# Property [Concurrent Option Type]: the concurrent option must be a boolean, so +# a value of any non-boolean BSON type is rejected with no coercion. A null +# concurrent is treated as the default, so it is excluded. +SEARCHMETA_CONCURRENT_OPTION_TYPE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"concurrent_type_{tid}", + pipeline=[ + {"$searchMeta": {"text": {"query": "quick", "path": "title"}, "concurrent": val}} + ], + error_code=UNKNOWN_ERROR, + msg=f"$searchMeta should reject a {tid} concurrent option as a non-boolean", + ) + for tid, val in [ + ("string", "true"), + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("object", {"a": 1}), + ("array", [True]), + ("objectid", ObjectId("0123456789abcdef01234567")), + ("datetime", datetime.datetime(2020, 1, 1)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ] +] + +SEARCHMETA_OPTION_TESTS: list[StageTestCase] = ( + SEARCHMETA_CONCURRENT_OPTION_TESTS + SEARCHMETA_CONCURRENT_OPTION_TYPE_ERROR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCHMETA_OPTION_TESTS)) +def test_searchMeta_options(search_collection, test_case: StageTestCase): + """Test $searchMeta concurrent stage option.""" + result = execute_command( + search_collection, + { + "aggregate": search_collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_path_forms.py b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_path_forms.py new file mode 100644 index 000000000..b0ad04ca6 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_path_forms.py @@ -0,0 +1,145 @@ +"""Tests for $searchMeta operator path-construction forms and multi-analyzer paths.""" + +from __future__ import annotations + +from collections.abc import Iterator + +import pytest +from bson import Int64 +from pymongo.collection import Collection + +from documentdb_tests.compatibility.tests.core.operator.stages.searchMeta.utils.searchMeta_common import ( # noqa: E501 + CollectionFixtureTestCase, + build_collection, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import UNKNOWN_ERROR +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = pytest.mark.requires(search=True) + + +@pytest.fixture(scope="module") +def multi_analyzer_collection(engine_client, worker_id) -> Iterator[Collection]: + """Collection whose title field carries a keyword multi-analyzer beside the default.""" + with build_collection( + engine_client, + worker_id, + f"{__name__}::multi_analyzer_collection", + "searchmeta_multi_analyzer", + [ + {"_id": 1, "title": "the quick brown fox"}, + {"_id": 2, "title": "quick red fox"}, + {"_id": 3, "title": "quick"}, + {"_id": 4, "title": "slow green turtle"}, + ], + [ + { + "name": "default", + "definition": { + "mappings": { + "dynamic": False, + "fields": { + "title": { + "type": "string", + "analyzer": "lucene.standard", + "multi": {"kw": {"type": "string", "analyzer": "lucene.keyword"}}, + } + }, + } + }, + } + ], + ) as coll: + yield coll + + +# Property [Operator Path Forms]: the embedded operator path accepts an array of +# field paths, a {value} document, and a {wildcard} document, each resolving to +# its covered field(s) and returning the match count as metadata. +SEARCHMETA_PATH_FORM_TESTS: list[CollectionFixtureTestCase] = [ + CollectionFixtureTestCase( + "path_array", + collection_fixture="search_collection", + pipeline=[{"$searchMeta": {"text": {"query": "quick", "path": ["title", "cat"]}}}], + expected=[{"count": {"lowerBound": Int64(3)}}], + msg="$searchMeta should accept an array of paths and return the match count across them", + ), + CollectionFixtureTestCase( + "path_value_document", + collection_fixture="search_collection", + pipeline=[{"$searchMeta": {"text": {"query": "quick", "path": {"value": "title"}}}}], + expected=[{"count": {"lowerBound": Int64(3)}}], + msg="$searchMeta should accept a {value} path document and return the match count", + ), + CollectionFixtureTestCase( + "path_wildcard_document", + collection_fixture="search_collection", + pipeline=[{"$searchMeta": {"text": {"query": "quick", "path": {"wildcard": "*"}}}}], + expected=[{"count": {"lowerBound": Int64(3)}}], + msg="$searchMeta should accept a {wildcard} path document spanning the covered fields", + ), + CollectionFixtureTestCase( + "path_multi_absent_analyzer", + collection_fixture="search_collection", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": {"value": "title", "multi": "nope"}} + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject a {value, multi} path referencing an undefined " + "multi-analyzer", + ), +] + +# Property [Operator Path Multi-Analyzer Selection]: a {value, multi} path +# resolves through the named alternate analyzer, so its match count differs from +# the field's default analyzer for the same query. +SEARCHMETA_PATH_MULTI_ANALYZER_TESTS: list[CollectionFixtureTestCase] = [ + CollectionFixtureTestCase( + "path_default_analyzer", + collection_fixture="multi_analyzer_collection", + pipeline=[{"$searchMeta": {"text": {"query": "quick", "path": "title"}}}], + expected=[{"count": {"lowerBound": Int64(3)}}], + msg="$searchMeta should count every standard-analyzed title containing the term", + ), + CollectionFixtureTestCase( + "path_multi_keyword_analyzer", + collection_fixture="multi_analyzer_collection", + pipeline=[ + {"$searchMeta": {"text": {"query": "quick", "path": {"value": "title", "multi": "kw"}}}} + ], + expected=[{"count": {"lowerBound": Int64(1)}}], + msg="$searchMeta should count only the keyword-analyzed title equal to the term when the " + "multi keyword analyzer is selected", + ), +] + +SEARCHMETA_PATH_TESTS: list[CollectionFixtureTestCase] = ( + SEARCHMETA_PATH_FORM_TESTS + SEARCHMETA_PATH_MULTI_ANALYZER_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCHMETA_PATH_TESTS)) +def test_searchMeta_path_forms(engine_client, request, test_case: CollectionFixtureTestCase): + """Test $searchMeta operator path forms and multi-analyzer path selection.""" + collection = request.getfixturevalue(test_case.collection_fixture) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_return_scope.py b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_return_scope.py new file mode 100644 index 000000000..30a6eeb0f --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_return_scope.py @@ -0,0 +1,258 @@ +"""Tests for the $searchMeta returnScope option (validation, semantics, success).""" + +from __future__ import annotations + +import datetime +from collections.abc import Iterator + +import pytest +from bson import Binary, Code, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp +from pymongo.collection import Collection + +from documentdb_tests.compatibility.tests.core.operator.stages.searchMeta.utils.searchMeta_common import ( # noqa: E501 + CollectionFixtureTestCase, + build_collection, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import UNKNOWN_ERROR +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import DECIMAL128_ONE_AND_HALF + +pytestmark = pytest.mark.requires(search=True) + + +@pytest.fixture(scope="module") +def nested_collection(engine_client, worker_id) -> Iterator[Collection]: + """Collection with nested embeddedDocuments and a stored source on the parent. + + returnScope requires the scope path to be indexed as an embeddedDocuments + field carrying a non-empty storedSource, so the parent ``groups`` field sets + ``storedSource`` and nests the ``tags`` embeddedDocuments the operator targets. + """ + with build_collection( + engine_client, + worker_id, + f"{__name__}::nested_collection", + "searchmeta_return_scope", + [ + {"_id": 1, "groups": [{"tags": [{"name": "quick"}, {"name": "slow"}]}]}, + {"_id": 2, "groups": [{"tags": [{"name": "quick"}]}]}, + {"_id": 3, "groups": [{"tags": [{"name": "lazy"}]}]}, + ], + [ + { + "name": "default", + "definition": { + "mappings": { + "dynamic": False, + "fields": { + "groups": { + "type": "embeddedDocuments", + "storedSource": True, + "fields": {"tags": {"type": "embeddedDocuments", "dynamic": True}}, + } + }, + } + }, + } + ], + ) as coll: + yield coll + + +# Property [ReturnScope Type]: the returnScope option must be a document, so a +# value of any non-document BSON type is rejected. A null returnScope is treated +# as the default, so it is excluded. +SEARCHMETA_RETURN_SCOPE_TYPE_ERROR_TESTS: list[CollectionFixtureTestCase] = [ + CollectionFixtureTestCase( + f"return_scope_type_{tid}", + collection_fixture="search_collection", + pipeline=[ + {"$searchMeta": {"text": {"query": "quick", "path": "title"}, "returnScope": val}} + ], + error_code=UNKNOWN_ERROR, + msg=f"$searchMeta should reject a {tid} returnScope option as a non-document", + ) + for tid, val in [ + ("string", "title"), + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("bool", True), + ("array", [{"path": "title"}]), + ("objectid", ObjectId("0123456789abcdef01234567")), + ("datetime", datetime.datetime(2020, 1, 1)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ] +] + +# Property [ReturnScope Path Required]: a returnScope document must carry a path +# field, so a document missing it is rejected. +SEARCHMETA_RETURN_SCOPE_PATH_REQUIRED_ERROR_TESTS: list[CollectionFixtureTestCase] = [ + CollectionFixtureTestCase( + "return_scope_empty", + collection_fixture="search_collection", + pipeline=[ + {"$searchMeta": {"text": {"query": "quick", "path": "title"}, "returnScope": {}}} + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject a returnScope document with no path field", + ), + CollectionFixtureTestCase( + "return_scope_other_field", + collection_fixture="search_collection", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "returnScope": {"other": "title"}, + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject a returnScope document that omits the required path field", + ), +] + +# Property [ReturnScope Path Type]: the returnScope path field must be a string, +# so a non-string path is rejected. +SEARCHMETA_RETURN_SCOPE_PATH_TYPE_ERROR_TESTS: list[CollectionFixtureTestCase] = [ + CollectionFixtureTestCase( + f"return_scope_path_type_{tid}", + collection_fixture="search_collection", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "returnScope": {"path": val}, + } + } + ], + error_code=UNKNOWN_ERROR, + msg=f"$searchMeta should reject a {tid} returnScope path as a non-string", + ) + for tid, val in [ + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("bool", True), + ("object", {"a": 1}), + ("array", ["title"]), + ("objectid", ObjectId("0123456789abcdef01234567")), + ("datetime", datetime.datetime(2020, 1, 1)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ] +] + +# Property [ReturnScope Scope Path Index Requirement]: a syntactically valid +# returnScope is rejected when its path is not indexed as an embeddedDocuments +# field carrying a non-empty stored source. +SEARCHMETA_RETURN_SCOPE_INDEX_REQUIREMENT_ERROR_TESTS: list[CollectionFixtureTestCase] = [ + CollectionFixtureTestCase( + "return_scope_path_not_embedded", + collection_fixture="search_collection", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "returnScope": {"path": "title"}, + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject a returnScope whose path is not indexed as an " + "embeddedDocuments field with a stored source", + ), +] + +# Property [ReturnScope Operator Path Containment]: the operator path must be a +# descendant of returnScope.path, so a returnScope path equal to the operator +# path is rejected. +SEARCHMETA_RETURN_SCOPE_CONTAINMENT_ERROR_TESTS: list[CollectionFixtureTestCase] = [ + CollectionFixtureTestCase( + "return_scope_path_not_ancestor", + collection_fixture="nested_collection", + pipeline=[ + { + "$searchMeta": { + "embeddedDocument": { + "path": "groups.tags", + "operator": {"text": {"query": "quick", "path": "groups.tags.name"}}, + }, + "returnScope": {"path": "groups.tags"}, + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject a returnScope path that is not a strict ancestor of the " + "operator path", + ), +] + +# Property [ReturnScope Acceptance]: a returnScope whose path is an ancestor of +# the operator path on an embeddedDocuments field with a stored source is +# accepted, and because a metadata stage returns no documents the count envelope +# is returned unchanged. +SEARCHMETA_RETURN_SCOPE_SUCCESS_TESTS: list[CollectionFixtureTestCase] = [ + CollectionFixtureTestCase( + "return_scope_ancestor_path", + collection_fixture="nested_collection", + pipeline=[ + { + "$searchMeta": { + "embeddedDocument": { + "path": "groups.tags", + "operator": {"text": {"query": "quick", "path": "groups.tags.name"}}, + }, + "returnScope": {"path": "groups"}, + } + } + ], + expected=[{"count": {"lowerBound": Int64(2)}}], + msg="$searchMeta should accept a returnScope ancestor path and still return only the " + "match count", + ), +] + +SEARCHMETA_RETURN_SCOPE_TESTS: list[CollectionFixtureTestCase] = ( + SEARCHMETA_RETURN_SCOPE_TYPE_ERROR_TESTS + + SEARCHMETA_RETURN_SCOPE_PATH_REQUIRED_ERROR_TESTS + + SEARCHMETA_RETURN_SCOPE_PATH_TYPE_ERROR_TESTS + + SEARCHMETA_RETURN_SCOPE_INDEX_REQUIREMENT_ERROR_TESTS + + SEARCHMETA_RETURN_SCOPE_CONTAINMENT_ERROR_TESTS + + SEARCHMETA_RETURN_SCOPE_SUCCESS_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCHMETA_RETURN_SCOPE_TESTS)) +def test_searchMeta_return_scope(engine_client, request, test_case: CollectionFixtureTestCase): + """Test $searchMeta returnScope validation, scope-path semantics, and acceptance.""" + collection = request.getfixturevalue(test_case.collection_fixture) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_spec_count_errors.py b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_spec_count_errors.py new file mode 100644 index 000000000..0f9b6d021 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_spec_count_errors.py @@ -0,0 +1,346 @@ +"""Tests for $searchMeta count specification errors.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import UNKNOWN_ERROR +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + INT32_OVERFLOW, + INT32_UNDERFLOW, +) + +pytestmark = pytest.mark.requires(search=True) + + +# Property [Count Not A Document]: a present count value that is not a document +# is rejected. +SEARCHMETA_COUNT_TYPE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"count_not_document_{tid}", + pipeline=[{"$searchMeta": {"text": {"query": "quick", "path": "title"}, "count": val}}], + error_code=UNKNOWN_ERROR, + msg=f"$searchMeta should reject a {tid} count value as not a document", + ) + for tid, val in [ + ("string", "x"), + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", DECIMAL128_ZERO), + ("bool", True), + ("array", [1, 2]), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Count Type Not A String]: a count.type value that is not a string is +# rejected. +SEARCHMETA_COUNT_TYPE_NOT_STRING_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"count_type_not_string_{tid}", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "count": {"type": val}, + } + } + ], + error_code=UNKNOWN_ERROR, + msg=f"$searchMeta should reject a {tid} count.type value as not a string", + ) + for tid, val in [ + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", DECIMAL128_ZERO), + ("bool", True), + ("array", [1, 2]), + ("object", {"a": 1}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Count Type Unknown String]: a count.type string outside +# {lowerBound, total} is rejected, with matching being case-sensitive and not +# whitespace-trimmed. +SEARCHMETA_COUNT_TYPE_VALUE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"count_type_value_{suffix}", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "count": {"type": value}, + } + } + ], + error_code=UNKNOWN_ERROR, + msg=f"$searchMeta should reject a {suffix} count.type string as an unknown count type", + ) + for value, suffix in [ + ("Total", "capitalized_total"), + ("total ", "trailing_space_total"), + ("foo", "foo"), + ] +] + +# Property [Count Threshold Non-Integer Type]: a count.threshold of a +# non-integer-valued type is rejected, including decimal128 even when its value +# is whole. +SEARCHMETA_COUNT_THRESHOLD_TYPE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"count_threshold_type_{tid}", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "count": {"threshold": val}, + } + } + ], + error_code=UNKNOWN_ERROR, + msg=f"$searchMeta should reject a {tid} count.threshold value as not an integer", + ) + for tid, val in [ + ("string", "x"), + ("bool", True), + ("array", [1, 2]), + ("object", {"a": 1}), + ("decimal128_whole", Decimal128("2")), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Count Threshold Non-Integral Double]: a count.threshold that is a +# fractional double or NaN is rejected. +SEARCHMETA_COUNT_THRESHOLD_FRACTIONAL_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"count_threshold_fractional_{suffix}", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "count": {"threshold": val}, + } + } + ], + error_code=UNKNOWN_ERROR, + msg=f"$searchMeta should reject a {suffix} count.threshold double as non-integral", + ) + for val, suffix in [ + (2.5, "fractional"), + (FLOAT_NAN, "nan"), + ] +] + +# Property [Count Threshold Negative]: a negative count.threshold is rejected. +SEARCHMETA_COUNT_THRESHOLD_NEGATIVE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"count_threshold_negative_{suffix}", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "count": {"threshold": val}, + } + } + ], + error_code=UNKNOWN_ERROR, + msg=f"$searchMeta should reject a {suffix} count.threshold as negative", + ) + for val, suffix in [ + (-1, "minus_one"), + (-2.0, "negative_double"), + ] +] + +# Property [Count Threshold Overflow]: a count.threshold above int32 max is +# rejected. +SEARCHMETA_COUNT_THRESHOLD_OVERFLOW_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"count_threshold_overflow_{suffix}", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "count": {"threshold": val}, + } + } + ], + error_code=UNKNOWN_ERROR, + msg=f"$searchMeta should reject a {suffix} count.threshold above int32 max", + ) + for val, suffix in [ + (INT32_OVERFLOW, "int32_max_plus_one"), + (FLOAT_INFINITY, "infinity"), + ] +] + +# Property [Count Threshold Underflow]: a count.threshold below int32 min is +# rejected. +SEARCHMETA_COUNT_THRESHOLD_UNDERFLOW_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"count_threshold_underflow_{suffix}", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "count": {"threshold": val}, + } + } + ], + error_code=UNKNOWN_ERROR, + msg=f"$searchMeta should reject a {suffix} count.threshold below int32 min", + ) + for val, suffix in [ + (INT32_UNDERFLOW, "int32_min_minus_one"), + (FLOAT_NEGATIVE_INFINITY, "negative_infinity"), + ] +] + +# Property [Count Threshold Validated For Total]: count.threshold is validated +# for integrality, sign, and range even when count.type is total, where its +# value is otherwise ignored. +SEARCHMETA_COUNT_THRESHOLD_TOTAL_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "count_threshold_total_fractional", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "count": {"type": "total", "threshold": 2.5}, + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should validate count.threshold integrality even when count.type is " + "total", + ), + StageTestCase( + "count_threshold_total_negative", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "count": {"type": "total", "threshold": -1}, + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should validate count.threshold sign even when count.type is total", + ), + StageTestCase( + "count_threshold_total_overflow", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "count": {"type": "total", "threshold": INT32_OVERFLOW}, + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should validate count.threshold range even when count.type is total", + ), +] + +# Property [Count Unrecognized Sub-Field]: an unrecognized sub-field of count is +# rejected, with matching being case-sensitive. +SEARCHMETA_COUNT_UNKNOWN_FIELD_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"count_unknown_field_{suffix}", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "count": count_value, + } + } + ], + error_code=UNKNOWN_ERROR, + msg=f"$searchMeta should reject an unrecognized count sub-field {name!r}", + ) + for count_value, name, suffix in [ + ({"type": "total", "foo": 1}, "foo", "alongside_type"), + ({"bar": 1}, "bar", "solo"), + ({"Type": "total"}, "Type", "capitalized_type"), + ] +] + +SEARCHMETA_SPEC_COUNT_ERROR_TESTS: list[StageTestCase] = ( + SEARCHMETA_COUNT_TYPE_ERROR_TESTS + + SEARCHMETA_COUNT_TYPE_NOT_STRING_ERROR_TESTS + + SEARCHMETA_COUNT_TYPE_VALUE_ERROR_TESTS + + SEARCHMETA_COUNT_THRESHOLD_TYPE_ERROR_TESTS + + SEARCHMETA_COUNT_THRESHOLD_FRACTIONAL_ERROR_TESTS + + SEARCHMETA_COUNT_THRESHOLD_NEGATIVE_ERROR_TESTS + + SEARCHMETA_COUNT_THRESHOLD_OVERFLOW_ERROR_TESTS + + SEARCHMETA_COUNT_THRESHOLD_UNDERFLOW_ERROR_TESTS + + SEARCHMETA_COUNT_THRESHOLD_TOTAL_ERROR_TESTS + + SEARCHMETA_COUNT_UNKNOWN_FIELD_ERROR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCHMETA_SPEC_COUNT_ERROR_TESTS)) +def test_searchMeta_spec_count_errors(search_collection, test_case: StageTestCase): + """Test $searchMeta count specification errors.""" + result = execute_command( + search_collection, + { + "aggregate": search_collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_spec_facet_boundary_errors.py b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_spec_facet_boundary_errors.py new file mode 100644 index 000000000..73fbc3c86 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_spec_facet_boundary_errors.py @@ -0,0 +1,402 @@ +"""Tests for $searchMeta facet boundary, numBuckets, and token-mapping errors.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Decimal128, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import UNKNOWN_ERROR +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = pytest.mark.requires(search=True) + + +# Property [Facet Number Boundaries Validation]: number-facet boundaries must be +# two to a thousand distinct numbers in ascending order, so too few, too many, +# non-ascending, duplicate adjacent, or non-numeric boundaries are rejected. +SEARCHMETA_FACET_BOUNDARIES_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "boundaries_single_element", + pipeline=[ + { + "$searchMeta": { + "facet": {"facets": {"nf": {"type": "number", "path": "n", "boundaries": [0]}}} + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject a number facet with fewer than two boundaries", + ), + StageTestCase( + "boundaries_above_max", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": { + "nf": { + "type": "number", + "path": "n", + "boundaries": list(range(1_001)), + } + } + } + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject a number facet with more than the maximum boundary count", + ), + StageTestCase( + "boundaries_unsorted", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": {"nf": {"type": "number", "path": "n", "boundaries": [40, 0, 20]}} + } + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject non-ascending number facet boundaries", + ), + StageTestCase( + "boundaries_duplicate_adjacent", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": {"nf": {"type": "number", "path": "n", "boundaries": [0, 0, 25]}} + } + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject duplicate adjacent number facet boundaries as not distinct", + ), + StageTestCase( + "boundaries_non_numeric", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": {"nf": {"type": "number", "path": "n", "boundaries": ["a", "b"]}} + } + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject non-numeric number facet boundaries", + ), +] + +# Property [Facet Date Boundaries Validation]: date-facet boundaries must be two +# to a thousand distinct datetimes in ascending order, and numeric (non-datetime) +# boundaries are rejected. Too few, non-ascending, and duplicate adjacent +# boundaries are rejected as they are for number facets. +SEARCHMETA_FACET_DATE_BOUNDARIES_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "date_boundaries_numeric", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": {"nf": {"type": "date", "path": "n", "boundaries": [0, 25]}} + } + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject numeric boundaries for a date facet", + ), + StageTestCase( + "date_boundaries_single_element", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": { + "nf": { + "type": "date", + "path": "n", + "boundaries": [datetime(2024, 1, 1, tzinfo=timezone.utc)], + } + } + } + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject a date facet with fewer than two boundaries", + ), + StageTestCase( + "date_boundaries_unsorted", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": { + "nf": { + "type": "date", + "path": "n", + "boundaries": [ + datetime(2024, 12, 1, tzinfo=timezone.utc), + datetime(2024, 1, 1, tzinfo=timezone.utc), + datetime(2024, 6, 1, tzinfo=timezone.utc), + ], + } + } + } + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject non-ascending date facet boundaries", + ), + StageTestCase( + "date_boundaries_duplicate_adjacent", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": { + "nf": { + "type": "date", + "path": "n", + "boundaries": [ + datetime(2024, 1, 1, tzinfo=timezone.utc), + datetime(2024, 1, 1, tzinfo=timezone.utc), + datetime(2024, 6, 1, tzinfo=timezone.utc), + ], + } + } + } + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject duplicate adjacent date facet boundaries as not distinct", + ), +] + +# Property [Facet NumBuckets Bounds]: a string-facet numBuckets outside +# [1..1000] is rejected. +SEARCHMETA_FACET_NUMBUCKETS_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "numbuckets_zero", + pipeline=[ + { + "$searchMeta": { + "facet": {"facets": {"nf": {"type": "string", "path": "cat", "numBuckets": 0}}} + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject a numBuckets below the lower bound", + ), + StageTestCase( + "numbuckets_above_max", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": {"nf": {"type": "string", "path": "cat", "numBuckets": 1001}} + } + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject a numBuckets above the upper bound", + ), +] + +# Property [Facet NumBuckets Type]: numBuckets must be a whole-number integer, so +# non-integer-typed values, including fractional doubles and decimals, are +# rejected. A null numBuckets is treated as the default, so it is excluded. +SEARCHMETA_FACET_NUMBUCKETS_TYPE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"numbuckets_type_{tid}", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": {"nf": {"type": "string", "path": "cat", "numBuckets": val}} + } + } + } + ], + error_code=UNKNOWN_ERROR, + msg=f"$searchMeta should reject a {tid} numBuckets as not a whole-number integer", + ) + for tid, val in [ + ("string", "2"), + ("double_fractional", 2.5), + ("decimal128", Decimal128("2")), + ("bool", True), + ("object", {"a": 1}), + ("array", [2]), + ("objectid", ObjectId("0123456789abcdef01234567")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Facet String Boundaries Unrecognized]: a string facet rejects the +# boundaries field as unrecognized. +SEARCHMETA_FACET_STRING_BOUNDARIES_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "string_facet_with_boundaries", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": {"nf": {"type": "string", "path": "cat", "boundaries": [0, 25]}} + } + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject boundaries on a string facet as an unrecognized field", + ), +] + +# Property [Facet Number NumBuckets Unrecognized]: a number facet rejects the +# numBuckets field as unrecognized. +SEARCHMETA_FACET_NUMBER_NUMBUCKETS_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "number_facet_with_numbuckets", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": { + "nf": { + "type": "number", + "path": "n", + "boundaries": [0, 25], + "numBuckets": 1, + } + } + } + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject numBuckets on a number facet as an unrecognized field", + ), +] + +# Property [Facet String Default Unrecognized]: a string facet rejects the +# default field as unrecognized, since only number and date facets carry a +# default overflow bucket. +SEARCHMETA_FACET_STRING_DEFAULT_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "string_facet_with_default", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": {"nf": {"type": "string", "path": "cat", "default": "other"}} + } + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject default on a string facet as an unrecognized field", + ), +] + +# Property [Facet Date NumBuckets Unrecognized]: a date facet rejects the +# numBuckets field as unrecognized, since only string facets carry numBuckets. +SEARCHMETA_FACET_DATE_NUMBUCKETS_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "date_facet_with_numbuckets", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": { + "nf": { + "type": "date", + "path": "d", + "boundaries": [ + datetime(2024, 1, 1, tzinfo=timezone.utc), + datetime(2024, 6, 1, tzinfo=timezone.utc), + ], + "numBuckets": 2, + } + } + } + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject numBuckets on a date facet as an unrecognized field", + ), +] + +# Property [Facet Token Mapping]: a string facet on a dynamically-indexed field +# is rejected because dynamic mapping does not token-index string fields. +SEARCHMETA_FACET_TOKEN_MAPPING_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "string_facet_dynamic_field", + pipeline=[ + {"$searchMeta": {"facet": {"facets": {"nf": {"type": "string", "path": "cat"}}}}} + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject string faceting on a dynamically-indexed string field", + ), +] + +SEARCHMETA_SPEC_FACET_BOUNDARY_ERROR_TESTS: list[StageTestCase] = ( + SEARCHMETA_FACET_BOUNDARIES_ERROR_TESTS + + SEARCHMETA_FACET_DATE_BOUNDARIES_ERROR_TESTS + + SEARCHMETA_FACET_NUMBUCKETS_ERROR_TESTS + + SEARCHMETA_FACET_NUMBUCKETS_TYPE_ERROR_TESTS + + SEARCHMETA_FACET_STRING_BOUNDARIES_ERROR_TESTS + + SEARCHMETA_FACET_NUMBER_NUMBUCKETS_ERROR_TESTS + + SEARCHMETA_FACET_STRING_DEFAULT_ERROR_TESTS + + SEARCHMETA_FACET_DATE_NUMBUCKETS_ERROR_TESTS + + SEARCHMETA_FACET_TOKEN_MAPPING_ERROR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCHMETA_SPEC_FACET_BOUNDARY_ERROR_TESTS)) +def test_searchMeta_spec_facet_boundary_errors(search_collection, test_case: StageTestCase): + """Test $searchMeta facet boundary, numBuckets, and token-mapping errors.""" + result = execute_command( + search_collection, + { + "aggregate": search_collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_spec_facet_errors.py b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_spec_facet_errors.py new file mode 100644 index 000000000..df20f215b --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_spec_facet_errors.py @@ -0,0 +1,414 @@ +"""Tests for $searchMeta facet collector and definition spec errors.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import UNKNOWN_ERROR +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import DECIMAL128_ZERO + +pytestmark = pytest.mark.requires(search=True) + + +# Property [Facet Value Not A Document]: a present facet value that is not a +# document is rejected. +SEARCHMETA_FACET_VALUE_TYPE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"facet_not_document_{tid}", + pipeline=[{"$searchMeta": {"facet": val}}], + error_code=UNKNOWN_ERROR, + msg=f"$searchMeta should reject a {tid} facet value as not a document", + ) + for tid, val in [ + ("string", "x"), + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", DECIMAL128_ZERO), + ("bool", True), + ("array", [1, 2]), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Facet Facets Required]: a facet collector whose facets field is +# omitted or null is rejected. +SEARCHMETA_FACETS_REQUIRED_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "facets_omitted", + pipeline=[{"$searchMeta": {"facet": {}}}], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject a facet collector with facets omitted", + ), + StageTestCase( + "facets_null", + pipeline=[{"$searchMeta": {"facet": {"facets": None}}}], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should treat a null facets value as field-absent and require facets", + ), +] + +# Property [Facet Facets Not A Document]: a present facet.facets value that is +# not a document is rejected. +SEARCHMETA_FACETS_TYPE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"facets_not_document_{tid}", + pipeline=[{"$searchMeta": {"facet": {"facets": val}}}], + error_code=UNKNOWN_ERROR, + msg=f"$searchMeta should reject a {tid} facet.facets value as not a document", + ) + for tid, val in [ + ("string", "x"), + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", DECIMAL128_ZERO), + ("bool", True), + ("array", [1, 2]), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Facet Facets Empty]: an empty facet.facets document is rejected. +SEARCHMETA_FACETS_EMPTY_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "facets_empty", + pipeline=[{"$searchMeta": {"facet": {"facets": {}}}}], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject an empty facet.facets document", + ), +] + +# Property [Facet Operator Empty]: an empty facet.operator document is rejected. +SEARCHMETA_FACET_OPERATOR_EMPTY_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "facet_operator_empty", + pipeline=[ + { + "$searchMeta": { + "facet": { + "operator": {}, + "facets": {"nf": {"type": "number", "path": "n", "boundaries": [0, 25]}}, + } + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject an empty facet operator by listing the valid operators", + ), +] + +# Property [Facet Definition Required Fields]: a facet definition missing its +# type or path is rejected. +SEARCHMETA_FACET_DEF_REQUIRED_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "facet_def_missing_type", + pipeline=[ + {"$searchMeta": {"facet": {"facets": {"nf": {"path": "n", "boundaries": [0, 25]}}}}} + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject a facet definition missing its type", + ), + StageTestCase( + "facet_def_missing_path", + pipeline=[ + { + "$searchMeta": { + "facet": {"facets": {"nf": {"type": "number", "boundaries": [0, 25]}}} + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject a facet definition missing its path", + ), + StageTestCase( + "facet_def_missing_boundaries", + pipeline=[{"$searchMeta": {"facet": {"facets": {"nf": {"type": "number", "path": "n"}}}}}], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject a number facet definition missing its boundaries", + ), +] + +# Property [Facet Definition Type Value]: a facet definition type outside +# {date, number, string} is rejected. +SEARCHMETA_FACET_DEF_TYPE_VALUE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "facet_def_type_unknown", + pipeline=[ + { + "$searchMeta": { + "facet": {"facets": {"nf": {"type": "foo", "path": "n", "boundaries": [0, 25]}}} + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject a facet definition type outside the allowed set", + ), +] + +# Property [Facet Definition Not A Document]: a facet definition value that is +# not a document is rejected. +SEARCHMETA_FACET_DEF_TYPE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"facet_def_not_document_{tid}", + pipeline=[{"$searchMeta": {"facet": {"facets": {"nf": val}}}}], + error_code=UNKNOWN_ERROR, + msg=f"$searchMeta should reject a {tid} facet definition value as not a document", + ) + for tid, val in [ + ("string", "x"), + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", DECIMAL128_ZERO), + ("bool", True), + ("array", [1, 2]), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Facet Path Type]: a facet definition path must be a string, so a +# non-string path is rejected. A null path is treated as missing, so it is +# excluded. +SEARCHMETA_FACET_PATH_TYPE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"facet_path_type_{tid}", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": {"nf": {"type": "number", "path": val, "boundaries": [0, 25]}} + } + } + } + ], + error_code=UNKNOWN_ERROR, + msg=f"$searchMeta should reject a {tid} facet path as not a string", + ) + for tid, val in [ + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", DECIMAL128_ZERO), + ("bool", True), + ("object", {"a": 1}), + ("array", ["n"]), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Facet Boundaries Type]: a number-facet boundaries value must be an +# array, so a non-array boundaries value is rejected. A null boundaries is +# treated as missing, so it is excluded. +SEARCHMETA_FACET_BOUNDARIES_TYPE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"facet_boundaries_type_{tid}", + pipeline=[ + { + "$searchMeta": { + "facet": {"facets": {"nf": {"type": "number", "path": "n", "boundaries": val}}} + } + } + ], + error_code=UNKNOWN_ERROR, + msg=f"$searchMeta should reject a {tid} boundaries value as not an array", + ) + for tid, val in [ + ("string", "x"), + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", DECIMAL128_ZERO), + ("bool", True), + ("object", {"a": 1}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Facet Default Type]: a number-facet default overflow bucket name +# must be a string, so a non-string default is rejected. A null default is +# treated as no default, so it is excluded. +SEARCHMETA_FACET_DEFAULT_TYPE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"facet_default_type_{tid}", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": { + "nf": { + "type": "number", + "path": "n", + "boundaries": [0, 5], + "default": val, + } + } + } + } + } + ], + error_code=UNKNOWN_ERROR, + msg=f"$searchMeta should reject a {tid} facet default as not a string", + ) + for tid, val in [ + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", DECIMAL128_ZERO), + ("bool", True), + ("object", {"a": 1}), + ("array", ["over"]), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Facet Unrecognized Field]: an unrecognized field at the facet +# definition or collector level, including a count modifier placed inside the +# collector, is rejected. +SEARCHMETA_FACET_UNKNOWN_FIELD_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "facet_def_unknown_field", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": { + "nf": { + "type": "number", + "path": "n", + "boundaries": [0, 25], + "bogus": 1, + } + } + } + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject an unrecognized field in a facet definition", + ), + StageTestCase( + "facet_collector_unknown_field", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": {"nf": {"type": "number", "path": "n", "boundaries": [0, 25]}}, + "bogus": 1, + } + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject an unrecognized field at the facet collector level", + ), + StageTestCase( + "facet_collector_count_inside", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": {"nf": {"type": "number", "path": "n", "boundaries": [0, 25]}}, + "count": {"type": "total"}, + } + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject a count modifier placed inside the facet collector", + ), +] + +SEARCHMETA_SPEC_FACET_ERROR_TESTS: list[StageTestCase] = ( + SEARCHMETA_FACET_VALUE_TYPE_ERROR_TESTS + + SEARCHMETA_FACETS_REQUIRED_ERROR_TESTS + + SEARCHMETA_FACETS_TYPE_ERROR_TESTS + + SEARCHMETA_FACETS_EMPTY_ERROR_TESTS + + SEARCHMETA_FACET_OPERATOR_EMPTY_ERROR_TESTS + + SEARCHMETA_FACET_DEF_REQUIRED_ERROR_TESTS + + SEARCHMETA_FACET_DEF_TYPE_VALUE_ERROR_TESTS + + SEARCHMETA_FACET_DEF_TYPE_ERROR_TESTS + + SEARCHMETA_FACET_PATH_TYPE_ERROR_TESTS + + SEARCHMETA_FACET_BOUNDARIES_TYPE_ERROR_TESTS + + SEARCHMETA_FACET_DEFAULT_TYPE_ERROR_TESTS + + SEARCHMETA_FACET_UNKNOWN_FIELD_ERROR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCHMETA_SPEC_FACET_ERROR_TESTS)) +def test_searchMeta_spec_facet_errors(search_collection, test_case: StageTestCase): + """Test $searchMeta facet collector and definition spec errors.""" + result = execute_command( + search_collection, + { + "aggregate": search_collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_spec_operator_errors.py b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_spec_operator_errors.py new file mode 100644 index 000000000..f0d6c6f23 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_spec_operator_errors.py @@ -0,0 +1,222 @@ +"""Tests for $searchMeta operator/collector presence and index spec errors.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import UNKNOWN_ERROR +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import DECIMAL128_ZERO + +pytestmark = pytest.mark.requires(search=True) + + +# Property [Operator Value Not A Document]: a present operator-key value that is +# not a document is rejected. +SEARCHMETA_OPERATOR_VALUE_TYPE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"operator_value_{tid}", + pipeline=[{"$searchMeta": {"text": val}}], + error_code=UNKNOWN_ERROR, + msg=f"$searchMeta should reject a {tid} operator value as not a document", + ) + for tid, val in [ + ("string", "x"), + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", DECIMAL128_ZERO), + ("bool", True), + ("array", [1, 2]), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [No Operator Or Collector Present]: a spec that the server reads as +# having no recognized operator and no collector is rejected. +SEARCHMETA_NO_OPERATOR_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "no_operator_unknown_name", + pipeline=[{"$searchMeta": {"unknownop": {"query": "quick", "path": "title"}}}], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject an unrecognized operator name as no operator present", + ), + StageTestCase( + "no_operator_empty_spec", + pipeline=[{"$searchMeta": {}}], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject an empty spec as no operator present", + ), + StageTestCase( + "no_operator_modifier_only", + pipeline=[{"$searchMeta": {"count": {"type": "total"}}}], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject a spec with only modifiers as no operator present", + ), + StageTestCase( + "no_operator_capitalized_key", + pipeline=[{"$searchMeta": {"Text": {"query": "quick", "path": "title"}}}], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should treat a capitalized operator key as unrecognized, not present", + ), + StageTestCase( + "no_operator_untrimmed_key", + pipeline=[{"$searchMeta": {"text ": {"query": "quick", "path": "title"}}}], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should treat an untrimmed operator key as unrecognized, not present", + ), + StageTestCase( + "no_operator_value_null", + pipeline=[{"$searchMeta": {"text": None}}], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should treat a null operator value as field-absent, not present", + ), + StageTestCase( + "no_operator_facet_null", + pipeline=[{"$searchMeta": {"facet": None}}], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should treat a null facet value as field-absent, not present", + ), +] + +# Property [Conflicting Operators And Collector]: a spec carrying more than one +# query specifier (a top-level operator plus a facet collector, or two top-level +# operators) is rejected. +SEARCHMETA_OPERATOR_CONFLICT_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "conflict_operator_and_collector", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "facet": { + "facets": {"nf": {"type": "number", "path": "n", "boundaries": [0, 25]}} + }, + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject a spec carrying both an operator and a facet collector", + ), + StageTestCase( + "conflict_two_operators", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "equals": {"path": "n", "value": 1}, + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject a spec carrying two top-level operators", + ), +] + +# Property [Index Not A String]: an index value that is not a string and not +# null is rejected. +SEARCHMETA_INDEX_TYPE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"index_not_string_{tid}", + pipeline=[{"$searchMeta": {"text": {"query": "quick", "path": "title"}, "index": val}}], + error_code=UNKNOWN_ERROR, + msg=f"$searchMeta should reject a {tid} index value as not a string", + ) + for tid, val in [ + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", DECIMAL128_ZERO), + ("bool", True), + ("array", [1, 2]), + ("object", {"a": 1}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Index Empty String]: an empty-string index is rejected. +SEARCHMETA_INDEX_EMPTY_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "index_empty_string", + pipeline=[{"$searchMeta": {"text": {"query": "quick", "path": "title"}, "index": ""}}], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject an empty-string index value", + ), +] + +# Property [Unknown Top-Level Option]: a top-level field that is not a recognized +# option is rejected; matching is exact, case-sensitive, and not +# whitespace-trimmed. ($search output options like sort/highlight are accepted as +# no-ops, so they are not unrecognized and not asserted here.) +SEARCHMETA_UNKNOWN_OPTION_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"unknown_option_{suffix}", + pipeline=[{"$searchMeta": {"text": {"query": "quick", "path": "title"}, name: value}}], + error_code=UNKNOWN_ERROR, + msg=f"$searchMeta should reject a {suffix} top-level option as an unrecognized field", + ) + for name, value, suffix in [ + ("bogus", 1, "garbage_name"), + ("Index", "default", "capitalized_index"), + ("index ", "default", "trailing_space_index"), + ] +] + +SEARCHMETA_SPEC_OPERATOR_ERROR_TESTS: list[StageTestCase] = ( + SEARCHMETA_OPERATOR_VALUE_TYPE_ERROR_TESTS + + SEARCHMETA_NO_OPERATOR_ERROR_TESTS + + SEARCHMETA_OPERATOR_CONFLICT_ERROR_TESTS + + SEARCHMETA_INDEX_TYPE_ERROR_TESTS + + SEARCHMETA_INDEX_EMPTY_ERROR_TESTS + + SEARCHMETA_UNKNOWN_OPTION_ERROR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCHMETA_SPEC_OPERATOR_ERROR_TESTS)) +def test_searchMeta_spec_operator_errors(search_collection, test_case: StageTestCase): + """Test $searchMeta operator/collector presence and index spec errors.""" + result = execute_command( + search_collection, + { + "aggregate": search_collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_stored_source.py b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_stored_source.py new file mode 100644 index 000000000..002f8caf9 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_stored_source.py @@ -0,0 +1,171 @@ +"""Tests for the $searchMeta returnStoredSource option and behavior.""" + +from __future__ import annotations + +import datetime +from collections.abc import Iterator + +import pytest +from bson import Binary, Code, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp +from pymongo.collection import Collection + +from documentdb_tests.compatibility.tests.core.operator.stages.searchMeta.utils.searchMeta_common import ( # noqa: E501 + CollectionFixtureTestCase, + build_collection, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import UNKNOWN_ERROR +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import DECIMAL128_ONE_AND_HALF + +pytestmark = pytest.mark.requires(search=True) + + +_STORED_SOURCE_DOCS = [ + {"_id": 1, "title": "the quick brown fox", "body": "lazy dog"}, + {"_id": 2, "title": "slow green turtle", "body": "quick nap"}, + {"_id": 3, "title": "a quick quick rabbit", "body": "fast"}, +] + + +@pytest.fixture(scope="module") +def stored_source_collection(engine_client, worker_id) -> Iterator[Collection]: + """Module-scoped collection whose index stores only the title source field.""" + with build_collection( + engine_client, + worker_id, + f"{__name__}::stored_source_collection", + "searchmeta_stored_source", + _STORED_SOURCE_DOCS, + [ + { + "name": "default", + "definition": { + "mappings": {"dynamic": True}, + "storedSource": {"include": ["title"]}, + }, + } + ], + ) as coll: + yield coll + + +# Property [ReturnStoredSource Acceptance]: returnStoredSource accepts a boolean +# with no coercion, and because a metadata stage returns no documents the count +# envelope is returned unchanged whether it is true or false. +SEARCHMETA_RETURN_STORED_SOURCE_TESTS: list[CollectionFixtureTestCase] = [ + CollectionFixtureTestCase( + "return_stored_source_false", + collection_fixture="search_collection", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "returnStoredSource": False, + } + } + ], + expected=[{"count": {"lowerBound": Int64(3)}}], + msg="$searchMeta should accept returnStoredSource false and still return its count", + ), + CollectionFixtureTestCase( + "return_stored_source_true", + collection_fixture="stored_source_collection", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "returnStoredSource": True, + } + } + ], + expected=[{"count": {"lowerBound": Int64(2)}}], + msg="$searchMeta should accept returnStoredSource true against a storedSource-configured " + "index and still return only the count", + ), +] + +# Property [ReturnStoredSource Without Configured Source]: returnStoredSource true +# against an index that does not configure storedSource is rejected. +SEARCHMETA_RETURN_STORED_SOURCE_CONFIG_ERROR_TESTS: list[CollectionFixtureTestCase] = [ + CollectionFixtureTestCase( + "return_stored_source_unconfigured", + collection_fixture="search_collection", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "returnStoredSource": True, + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject returnStoredSource true against an index with no " + "storedSource configured", + ), +] + +# Property [ReturnStoredSource Boolean Type]: the returnStoredSource option must +# be a boolean, so a value of any non-boolean BSON type is rejected. A null +# returnStoredSource is treated as the default, so it is excluded. +SEARCHMETA_RETURN_STORED_SOURCE_TYPE_ERROR_TESTS: list[CollectionFixtureTestCase] = [ + CollectionFixtureTestCase( + f"return_stored_source_type_{tid}", + collection_fixture="search_collection", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "returnStoredSource": val, + } + } + ], + error_code=UNKNOWN_ERROR, + msg=f"$searchMeta should reject a {tid} returnStoredSource as a non-boolean", + ) + for tid, val in [ + ("string", "true"), + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("object", {"a": 1}), + ("array", [True]), + ("objectid", ObjectId("0123456789abcdef01234567")), + ("datetime", datetime.datetime(2020, 1, 1)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ] +] + +SEARCHMETA_STORED_SOURCE_TESTS: list[CollectionFixtureTestCase] = ( + SEARCHMETA_RETURN_STORED_SOURCE_TESTS + + SEARCHMETA_RETURN_STORED_SOURCE_CONFIG_ERROR_TESTS + + SEARCHMETA_RETURN_STORED_SOURCE_TYPE_ERROR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCHMETA_STORED_SOURCE_TESTS)) +def test_searchMeta_stored_source(engine_client, request, test_case: CollectionFixtureTestCase): + """Test $searchMeta returnStoredSource acceptance, config, and type validation.""" + collection = request.getfixturevalue(test_case.collection_fixture) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_string_facet.py b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_string_facet.py new file mode 100644 index 000000000..c0c5e7475 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_string_facet.py @@ -0,0 +1,198 @@ +"""Tests for $searchMeta string-facet bucket selection and ordering.""" + +from __future__ import annotations + +from collections.abc import Iterator + +import pytest +from bson import Int64 +from pymongo.collection import Collection + +from documentdb_tests.compatibility.tests.core.operator.stages.searchMeta.utils.searchMeta_common import ( # noqa: E501 + build_collection, +) +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, +) +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.test_constants import INT64_ZERO + +pytestmark = pytest.mark.requires(search=True) + + +@pytest.fixture(scope="module") +def string_facet_collection(engine_client, worker_id) -> Iterator[Collection]: + """Indexed collection with a token mapping for string faceting. + + String facets are rejected under a plain dynamic mapping, so this collection + declares an explicit token mapping. Category counts (a=3, b=2, c=1) make + truncation and ordering by count observable. + """ + with build_collection( + engine_client, + worker_id, + f"{__name__}::string_facet_collection", + "searchmeta_string_facet", + [ + {"_id": 1, "cat": "a"}, + {"_id": 2, "cat": "b"}, + {"_id": 3, "cat": "a"}, + {"_id": 4, "cat": "c"}, + {"_id": 5, "cat": "b"}, + {"_id": 6, "cat": "a"}, + ], + [ + { + "name": "default", + "definition": { + "mappings": {"dynamic": True, "fields": {"cat": [{"type": "token"}]}} + }, + } + ], + ) as coll: + yield coll + + +# Property [Facet String NumBuckets]: numBuckets greater than the distinct value +# count returns all buckets, while fewer truncates to the top-N values by count. +SEARCHMETA_STRING_NUMBUCKETS_TESTS: list[StageTestCase] = [ + StageTestCase( + "string_numbuckets_all", + pipeline=[ + { + "$searchMeta": { + "facet": {"facets": {"sf": {"type": "string", "path": "cat", "numBuckets": 10}}} + } + } + ], + expected=[ + { + "count": {"lowerBound": Int64(6)}, + "facet": { + "sf": { + "buckets": [ + {"_id": "a", "count": Int64(3)}, + {"_id": "b", "count": Int64(2)}, + {"_id": "c", "count": Int64(1)}, + ] + } + }, + } + ], + msg="$searchMeta string facet should return all buckets when numBuckets exceeds the " + "distinct value count", + ), + StageTestCase( + "string_numbuckets_topn", + pipeline=[ + { + "$searchMeta": { + "facet": {"facets": {"sf": {"type": "string", "path": "cat", "numBuckets": 2}}} + } + } + ], + expected=[ + { + "count": {"lowerBound": Int64(6)}, + "facet": { + "sf": { + "buckets": [ + {"_id": "a", "count": Int64(3)}, + {"_id": "b", "count": Int64(2)}, + ] + } + }, + } + ], + msg="$searchMeta string facet should truncate to the top-N values by count when " + "numBuckets is below the distinct value count", + ), +] + +# Property [Facet String NumBuckets Type Coercion]: numBuckets accepts any +# whole-number integer type, so an Int64 and a whole-number double behave like +# the equivalent int32. +SEARCHMETA_STRING_NUMBUCKETS_COERCION_TESTS: list[StageTestCase] = [ + StageTestCase( + f"string_numbuckets_{tid}", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": {"sf": {"type": "string", "path": "cat", "numBuckets": val}} + } + } + } + ], + expected=[ + { + "count": {"lowerBound": Int64(6)}, + "facet": { + "sf": { + "buckets": [ + {"_id": "a", "count": Int64(3)}, + {"_id": "b", "count": Int64(2)}, + ] + } + }, + } + ], + msg=f"$searchMeta string facet should accept a {tid} numBuckets and truncate to the " + "top-N values by count", + ) + for tid, val in [("int64", Int64(2)), ("whole_double", 2.0)] +] + +# Property [Facet Empty Buckets]: a no-match query yields an empty buckets array +# for a string facet, which emits only buckets backed by matching values. +SEARCHMETA_STRING_EMPTY_TESTS: list[StageTestCase] = [ + StageTestCase( + "string_facet_no_match_empty_buckets", + pipeline=[ + { + "$searchMeta": { + "facet": { + "operator": {"text": {"query": "nonexistentxyz", "path": "cat"}}, + "facets": {"sf": {"type": "string", "path": "cat"}}, + } + } + } + ], + expected=[ + { + "count": {"lowerBound": INT64_ZERO}, + "facet": {"sf": {"buckets": []}}, + } + ], + msg="$searchMeta string facet should return an empty buckets array when no document " + "matches the query", + ), +] + +SEARCHMETA_STRING_FACET_TESTS: list[StageTestCase] = ( + SEARCHMETA_STRING_NUMBUCKETS_TESTS + + SEARCHMETA_STRING_NUMBUCKETS_COERCION_TESTS + + SEARCHMETA_STRING_EMPTY_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCHMETA_STRING_FACET_TESTS)) +def test_searchMeta_string_facet(string_facet_collection, test_case: StageTestCase): + """Test $searchMeta string facet bucket selection and ordering.""" + result = execute_command( + string_facet_collection, + { + "aggregate": string_facet_collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_threshold_bounds.py b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_threshold_bounds.py new file mode 100644 index 000000000..b142a13c2 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_threshold_bounds.py @@ -0,0 +1,113 @@ +"""Tests for $searchMeta count behavior above the exact-count threshold.""" + +from __future__ import annotations + +from collections.abc import Iterator + +import pytest +from bson import Int64 +from pymongo.collection import Collection + +from documentdb_tests.compatibility.tests.core.operator.stages.searchMeta.utils.searchMeta_common import ( # noqa: E501 + LARGE_MATCH_COUNT, + build_collection, +) +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, +) +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.property_checks import Gte, Lte + +pytestmark = pytest.mark.requires(search=True) + + +@pytest.fixture(scope="module") +def large_search_collection(engine_client, worker_id) -> Iterator[Collection]: + """Large indexed collection where every document matches one query.""" + with build_collection( + engine_client, + worker_id, + f"{__name__}::large_search_collection", + "searchmeta_large", + [{"_id": i, "title": "widget"} for i in range(LARGE_MATCH_COUNT)], + [{"name": "default", "definition": {"mappings": {"dynamic": True}}}], + ) as coll: + yield coll + + +# Property [Count Type Above Threshold]: when the match count exceeds the +# threshold, count.type lowerBound returns a value between the threshold and the +# match count, while count.type total stays exact. +SEARCHMETA_THRESHOLD_BOUND_TESTS: list[StageTestCase] = [ + StageTestCase( + "threshold_bound_explicit_low", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "widget", "path": "title"}, + "count": {"type": "lowerBound", "threshold": 2000}, + } + } + ], + expected={"count": {"lowerBound": [Gte(2000), Lte(LARGE_MATCH_COUNT)]}}, + msg="$searchMeta count.type lowerBound should return at least the threshold and at " + "most the match count", + ), + StageTestCase( + "threshold_bound_explicit_high", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "widget", "path": "title"}, + "count": {"type": "lowerBound", "threshold": 5000}, + } + } + ], + expected={"count": {"lowerBound": [Gte(5000), Lte(LARGE_MATCH_COUNT)]}}, + msg="$searchMeta count.type lowerBound should track a higher threshold and stay at " + "most the match count", + ), + StageTestCase( + "threshold_bound_default", + pipeline=[{"$searchMeta": {"text": {"query": "widget", "path": "title"}}}], + expected={"count": {"lowerBound": [Gte(1000), Lte(LARGE_MATCH_COUNT)]}}, + msg="$searchMeta should apply a default threshold of 1000 when count is omitted, so the " + "result is at least 1000 and at most the match count", + ), + StageTestCase( + "threshold_bound_total_exact_above_threshold", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "widget", "path": "title"}, + "count": {"type": "total"}, + } + } + ], + expected=[{"count": {"total": Int64(LARGE_MATCH_COUNT)}}], + msg="$searchMeta count.type total should return the exact match count even when it far " + "exceeds the threshold", + ), +] + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCHMETA_THRESHOLD_BOUND_TESTS)) +def test_searchMeta_threshold_bounds(large_search_collection, test_case: StageTestCase): + """Test $searchMeta count behavior above the threshold.""" + result = execute_command( + large_search_collection, + { + "aggregate": large_search_collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_smoke_searchMeta.py b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_smoke_searchMeta.py index ec3983536..e250fcc2b 100644 --- a/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_smoke_searchMeta.py +++ b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_smoke_searchMeta.py @@ -1,18 +1,13 @@ -""" -Smoke test for $searchMeta stage. - -Tests basic $searchMeta stage functionality. -""" +"""Smoke test for the $searchMeta stage.""" import pytest from documentdb_tests.framework.assertions import assertSuccessPartial from documentdb_tests.framework.executor import execute_command -pytestmark = pytest.mark.smoke +pytestmark = [pytest.mark.smoke, pytest.mark.requires(search=True)] -@pytest.mark.skip(reason="Requires Atlas Search configuration - not available on standard MongoDB") def test_smoke_searchMeta(collection): """Test basic $searchMeta stage behavior.""" collection.insert_many([{"_id": 1, "title": "test document"}]) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/utils/__init__.py b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/utils/searchMeta_common.py b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/utils/searchMeta_common.py new file mode 100644 index 000000000..e08560f76 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/utils/searchMeta_common.py @@ -0,0 +1,110 @@ +"""Shared infrastructure for $searchMeta stage tests.""" + +from __future__ import annotations + +import time +from collections.abc import Iterator +from contextlib import contextmanager +from dataclasses import dataclass +from typing import Any + +from pymongo.collection import Collection +from pymongo.database import Database + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, +) +from documentdb_tests.framework.fixtures import cleanup_database, generate_database_name + + +@dataclass(frozen=True) +class CollectionFixtureTestCase(StageTestCase): + """A $searchMeta case whose collection is supplied by a named fixture. + + ``collection_fixture`` names the module-scoped fixture that builds the + search-indexed collection, letting cases targeting different collection + states share one parametrized test. A test using this must declare + ``engine_client`` directly so target parametrization fires before the + fixture resolves via ``request.getfixturevalue``. + """ + + collection_fixture: str = "" + + +# Shared dataset; the match counts the tests assert are derived from these +# documents. +SEARCH_DOCS: list[dict[str, Any]] = [ + {"_id": 1, "title": "the quick brown fox", "n": 1, "cat": "a"}, + {"_id": 2, "title": "quick red fox", "n": 5, "cat": "b"}, + {"_id": 3, "title": "lazy brown dog", "n": 10, "cat": "a"}, + {"_id": 4, "title": "slow green turtle", "n": 15, "cat": "c"}, + {"_id": 5, "title": "quick small mouse", "n": 20, "cat": "b"}, +] + +# Match count for the large collection, chosen to exceed the lower-bound count's +# exact-count threshold. Referenced by the fixture and the test expectations. +LARGE_MATCH_COUNT = 10_000 + + +def wait_for_ready(db: Database, name: str) -> None: + """Block until every search index on the collection reports READY.""" + # A search index build is asynchronous; allow generous time to reach READY. + deadline = time.monotonic() + 60 + while time.monotonic() < deadline: + batch = db.command({"listSearchIndexes": name})["cursor"]["firstBatch"] + if batch and all(idx.get("status") == "READY" for idx in batch): + return + time.sleep(1) + raise TimeoutError(f"search index on {name!r} did not reach READY") + + +@contextmanager +def build_collection( + engine_client, worker_id, tag: str, coll_name: str, docs, indexes +) -> Iterator[Collection]: + """Yield a module-scoped collection built from a single recipe. + + ``tag`` namespaces the database so distinct fixtures and modules never + collide. ``docs=None`` leaves the collection uncreated, ``docs=[]`` creates + it empty, and a non-empty list creates and populates it. When ``indexes`` is + given they are built as search indexes and awaited to READY. + """ + db_name = generate_database_name(tag, worker_id) + # Database name is deterministic, so drop any leftover from a crashed run. + cleanup_database(engine_client, db_name) + db = engine_client[db_name] + coll = db[coll_name] + try: + if docs is None: + pass + elif docs: + coll.insert_many(docs) + else: + db.create_collection(coll.name) + if indexes: + db.command({"createSearchIndexes": coll.name, "indexes": indexes}) + wait_for_ready(db, coll.name) + yield coll + finally: + cleanup_database(engine_client, db_name) + + +@contextmanager +def open_search_collection(engine_client, worker_id, tag: str) -> Iterator[Collection]: + """Yield the standard metadata search collection. + + Carries a "default" and a non-default "alt_idx" index so index-selection + cases can target each by name. + """ + with build_collection( + engine_client, + worker_id, + tag, + "searchmeta", + SEARCH_DOCS, + [ + {"name": "default", "definition": {"mappings": {"dynamic": True}}}, + {"name": "alt_idx", "definition": {"mappings": {"dynamic": True}}}, + ], + ) as coll: + yield coll diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/test_stages_position_searchMeta.py b/documentdb_tests/compatibility/tests/core/operator/stages/test_stages_position_searchMeta.py new file mode 100644 index 000000000..674070878 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/test_stages_position_searchMeta.py @@ -0,0 +1,300 @@ +"""Tests for $searchMeta pipeline position constraints and stage combinations.""" + +from __future__ import annotations + +from collections.abc import Iterator + +import pytest +from bson import Int64 +from pymongo.collection import Collection + +from documentdb_tests.compatibility.tests.core.operator.stages.searchMeta.utils.searchMeta_common import ( # noqa: E501 + SEARCH_DOCS, + CollectionFixtureTestCase, + build_collection, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + FACET_PIPELINE_INVALID_STAGE_ERROR, + NOT_FIRST_STAGE_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = pytest.mark.requires(search=True) + + +@pytest.fixture(scope="module") +def noindex_collection(engine_client, worker_id) -> Iterator[Collection]: + """Provide a populated collection that has no search index. + + Position and context errors are parse-time rejections, so this collection + deliberately omits the search index to confirm the position check fires + regardless of index state and to let sub-pipeline cases self-reference an + existing collection. + """ + with build_collection( + engine_client, + worker_id, + f"{__name__}::noindex_collection", + "searchmeta_noindex", + SEARCH_DOCS, + None, + ) as coll: + yield coll + + +@pytest.fixture(scope="module") +def search_collection(engine_client, worker_id) -> Iterator[Collection]: + """Provide a populated collection with a READY dynamic search index. + + Building a search index is an expensive asynchronous operation, so the + collection is created once per module and shared across the placement cases, + which only read from it. + """ + with build_collection( + engine_client, + worker_id, + f"{__name__}::search_collection", + "searchmeta", + SEARCH_DOCS, + [{"name": "default", "definition": {"mappings": {"dynamic": True}}}], + ) as coll: + yield coll + + +# Property [Stage Position and Context]: a first-position $searchMeta permits a +# single benign trailing stage with the one metadata document passing through, +# and $searchMeta is allowed as the first stage of a $unionWith or $lookup +# sub-pipeline. +SEARCHMETA_PLACEMENT_TESTS: list[CollectionFixtureTestCase] = [ + CollectionFixtureTestCase( + "placement_trailing_limit", + collection_fixture="search_collection", + pipeline=[ + {"$searchMeta": {"text": {"query": "quick", "path": "title"}}}, + {"$limit": 1}, + ], + expected=[{"count": {"lowerBound": Int64(3)}}], + msg="$searchMeta should permit a single trailing $limit and pass the one metadata " + "document through", + ), + CollectionFixtureTestCase( + "placement_unionwith_subpipeline", + collection_fixture="search_collection", + pipeline=[ + {"$searchMeta": {"text": {"query": "quick", "path": "title"}}}, + { + "$unionWith": { + "coll": "searchmeta", + "pipeline": [{"$searchMeta": {"text": {"query": "quick", "path": "title"}}}], + } + }, + ], + expected=[ + {"count": {"lowerBound": Int64(3)}}, + {"count": {"lowerBound": Int64(3)}}, + ], + msg="$searchMeta should be allowed as the first stage of a $unionWith sub-pipeline " + "alongside a first-position main $searchMeta", + ), + CollectionFixtureTestCase( + "placement_lookup_subpipeline", + collection_fixture="search_collection", + pipeline=[ + {"$searchMeta": {"text": {"query": "quick", "path": "title"}}}, + { + "$lookup": { + "from": "searchmeta", + "pipeline": [{"$searchMeta": {"text": {"query": "quick", "path": "title"}}}], + "as": "meta", + } + }, + ], + expected=[ + { + "count": {"lowerBound": Int64(3)}, + "meta": [{"count": {"lowerBound": Int64(3)}}], + } + ], + msg="$searchMeta should be allowed as the first stage of a $lookup sub-pipeline and " + "return the metadata document per joined row", + ), +] + +# Property [Stage Position and Context Errors]: $searchMeta is rejected with +# NOT_FIRST_STAGE_ERROR whenever it is not the first stage of its main pipeline +# or of a $unionWith/$lookup sub-pipeline, and with +# FACET_PIPELINE_INVALID_STAGE_ERROR inside a $facet sub-pipeline; the position +# check fires regardless of index state. +SEARCHMETA_POSITION_ERROR_TESTS: list[CollectionFixtureTestCase] = [ + CollectionFixtureTestCase( + "error_after_match", + collection_fixture="noindex_collection", + pipeline=[ + {"$match": {"n": {"$gt": 0}}}, + {"$searchMeta": {"text": {"query": "quick", "path": "title"}}}, + ], + error_code=NOT_FIRST_STAGE_ERROR, + msg="$searchMeta after a $match should be rejected as not the first stage", + ), + CollectionFixtureTestCase( + "error_after_project", + collection_fixture="noindex_collection", + pipeline=[ + {"$project": {"title": 1}}, + {"$searchMeta": {"text": {"query": "quick", "path": "title"}}}, + ], + error_code=NOT_FIRST_STAGE_ERROR, + msg="$searchMeta after a $project should be rejected as not the first stage", + ), + CollectionFixtureTestCase( + "error_after_limit", + collection_fixture="noindex_collection", + pipeline=[ + {"$limit": 1}, + {"$searchMeta": {"text": {"query": "quick", "path": "title"}}}, + ], + error_code=NOT_FIRST_STAGE_ERROR, + msg="$searchMeta after a $limit should be rejected as not the first stage", + ), + CollectionFixtureTestCase( + "error_after_addfields", + collection_fixture="noindex_collection", + pipeline=[ + {"$addFields": {"m": 1}}, + {"$searchMeta": {"text": {"query": "quick", "path": "title"}}}, + ], + error_code=NOT_FIRST_STAGE_ERROR, + msg="$searchMeta after an $addFields should be rejected as not the first stage", + ), + CollectionFixtureTestCase( + "error_after_empty_match", + collection_fixture="noindex_collection", + pipeline=[ + {"$match": {}}, + {"$searchMeta": {"text": {"query": "quick", "path": "title"}}}, + ], + error_code=NOT_FIRST_STAGE_ERROR, + msg="$searchMeta after an empty $match should still be rejected as not the first " + "stage because the empty $match is not optimized away", + ), + CollectionFixtureTestCase( + "error_second_searchMeta_adjacent", + collection_fixture="noindex_collection", + pipeline=[ + {"$searchMeta": {"text": {"query": "quick", "path": "title"}}}, + {"$searchMeta": {"text": {"query": "quick", "path": "title"}}}, + ], + error_code=NOT_FIRST_STAGE_ERROR, + msg="A second adjacent $searchMeta should be rejected as not the first stage", + ), + CollectionFixtureTestCase( + "error_second_searchMeta_separated", + collection_fixture="noindex_collection", + pipeline=[ + {"$searchMeta": {"text": {"query": "quick", "path": "title"}}}, + {"$limit": 1}, + {"$searchMeta": {"text": {"query": "quick", "path": "title"}}}, + ], + error_code=NOT_FIRST_STAGE_ERROR, + msg="A second $searchMeta separated from the first should be rejected as not the " + "first stage", + ), + CollectionFixtureTestCase( + "error_inside_facet_first", + collection_fixture="noindex_collection", + pipeline=[ + { + "$facet": { + "meta": [ + {"$searchMeta": {"text": {"query": "quick", "path": "title"}}}, + ], + } + }, + ], + error_code=FACET_PIPELINE_INVALID_STAGE_ERROR, + msg="$searchMeta as the first stage of a $facet sub-pipeline should be rejected", + ), + CollectionFixtureTestCase( + "error_inside_facet_not_first", + collection_fixture="noindex_collection", + pipeline=[ + { + "$facet": { + "meta": [ + {"$limit": 1}, + {"$searchMeta": {"text": {"query": "quick", "path": "title"}}}, + ], + } + }, + ], + error_code=FACET_PIPELINE_INVALID_STAGE_ERROR, + msg="$searchMeta inside a $facet sub-pipeline should be rejected regardless of its " + "position within the sub-pipeline", + ), + CollectionFixtureTestCase( + "error_unionwith_subpipeline_not_first", + collection_fixture="noindex_collection", + pipeline=[ + {"$searchMeta": {"text": {"query": "quick", "path": "title"}}}, + { + "$unionWith": { + "coll": "searchmeta_noindex", + "pipeline": [ + {"$match": {}}, + {"$searchMeta": {"text": {"query": "quick", "path": "title"}}}, + ], + } + }, + ], + error_code=NOT_FIRST_STAGE_ERROR, + msg="$searchMeta that is not first inside a $unionWith sub-pipeline should be " + "rejected as not the first stage", + ), + CollectionFixtureTestCase( + "error_lookup_subpipeline_not_first", + collection_fixture="noindex_collection", + pipeline=[ + {"$searchMeta": {"text": {"query": "quick", "path": "title"}}}, + { + "$lookup": { + "from": "searchmeta_noindex", + "pipeline": [ + {"$match": {}}, + {"$searchMeta": {"text": {"query": "quick", "path": "title"}}}, + ], + "as": "meta", + } + }, + ], + error_code=NOT_FIRST_STAGE_ERROR, + msg="$searchMeta that is not first inside a $lookup sub-pipeline should be " + "rejected as not the first stage", + ), +] + +SEARCHMETA_POSITION_TESTS: list[CollectionFixtureTestCase] = ( + SEARCHMETA_PLACEMENT_TESTS + SEARCHMETA_POSITION_ERROR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCHMETA_POSITION_TESTS)) +def test_searchMeta_position(engine_client, request, test_case: CollectionFixtureTestCase): + """Test $searchMeta pipeline position constraints, combinations, and rejections.""" + collection = request.getfixturevalue(test_case.collection_fixture) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/framework/engine_registry.py b/documentdb_tests/framework/engine_registry.py index 774ab7810..1e2bf1664 100644 --- a/documentdb_tests/framework/engine_registry.py +++ b/documentdb_tests/framework/engine_registry.py @@ -102,6 +102,15 @@ def _is_reachable(connection_string: str) -> bool: # replSetInitiate error code when the set is already initiated (e.g. a race # between concurrent callers); treated as success. _ALREADY_INITIALIZED = 23 +# createUser error code when the user already exists (idempotent re-runs). +_USER_ALREADY_EXISTS = 51003 + +# The user mongot authenticates as to replicate from a search-enabled mongod. +# Its name and password are a fixed local-dev secret matched by the mongot +# sidecar's config (see dev/mongot.yml and the mongot service in +# dev/compose.yaml); it is not a real credential. +_SEARCH_SYNC_USER = "searchSyncUser" +_SEARCH_SYNC_PASSWORD = "searchSyncPassword" def ensure_initiated(connection_string: str, timeout_s: float = 30.0) -> None: @@ -120,12 +129,14 @@ def ensure_initiated(connection_string: str, timeout_s: float = 30.0) -> None: that already initiated it (AlreadyInitialized) is tolerated. After initiating, it waits up to ``timeout_s`` for a primary to be elected - so callers can write immediately. + so callers can write immediately. A search-enabled mongod additionally has + the searchCoordinator user mongot needs provisioned once it is primary. """ client: MongoClient = MongoClient(connection_string, serverSelectionTimeoutMS=5000) try: try: client.admin.command("replSetGetStatus") + _ensure_search_user(client) # Idempotent; a no-op off a search target. return # Already initiated. except OperationFailure as exc: if exc.code != _NOT_YET_INITIALIZED: @@ -140,6 +151,7 @@ def ensure_initiated(connection_string: str, timeout_s: float = 30.0) -> None: deadline = time.monotonic() + timeout_s while time.monotonic() < deadline: if client.admin.command("hello").get("isWritablePrimary"): + _ensure_search_user(client) return time.sleep(0.5) raise TimeoutError( @@ -149,6 +161,29 @@ def ensure_initiated(connection_string: str, timeout_s: float = 30.0) -> None: client.close() +def _ensure_search_user(client: MongoClient) -> None: + """Provision the searchCoordinator user a search-enabled mongod needs. + + A search target points at a mongot sidecar (a non-empty ``mongotHost``). + mongot replicates from this mongod as an authenticated sync source, so it + needs a user with the searchCoordinator role to log in as. This creates that + user (idempotently) once the server is primary. It is a no-op on a target + without a mongot sidecar. + """ + if not client.admin.command({"getParameter": 1, "mongotHost": 1}).get("mongotHost"): + return # Not a search target. + try: + client.admin.command( + "createUser", + _SEARCH_SYNC_USER, + pwd=_SEARCH_SYNC_PASSWORD, + roles=[{"role": "searchCoordinator", "db": "admin"}], + ) + except OperationFailure as exc: + if exc.code != _USER_ALREADY_EXISTS: + raise + + def live_targets(compose_path: Path = COMPOSE_PATH) -> list[Target]: """Return the declared targets that are currently reachable.""" return [t for t in load_targets(compose_path) if _is_reachable(t.connection_string)] diff --git a/documentdb_tests/framework/preconditions.py b/documentdb_tests/framework/preconditions.py index a42e71716..561b9dec5 100644 --- a/documentdb_tests/framework/preconditions.py +++ b/documentdb_tests/framework/preconditions.py @@ -57,6 +57,7 @@ "unforced_compact": "compact succeeds without force", "reindex": "reIndex is permitted", "local_rename": "renaming into the unreplicated local database is permitted", + "search": "search and vector search surfaces are available", "replication": "replication commands are available (applyOps, oplog access)", "validate_repair": ( "validate with repair/fixMultikey is permitted and background validation " @@ -67,6 +68,10 @@ # The capabilities each (engine, topology) target has. To add an engine or # topology, add an entry here; every test then gates correctly. _CAPABILITIES_BY_PROFILE: dict[tuple[str, str], frozenset[str]] = { + # A replica set, wired to a mongot search sidecar so it also serves the + # search surfaces (see dev/compose.yaml). mongot is transparent to all other + # behavior, so this is a replica set that additionally has the search + # capability, not a distinct topology. ("mongodb", "replica_set"): frozenset( { "change_streams", @@ -76,6 +81,7 @@ "cluster_time", "cluster_read_concern", "quorum_write_concern", + "search", "oplog", "replication", } diff --git a/documentdb_tests/pytest.ini b/documentdb_tests/pytest.ini index b2cd6c4a1..0f577b18f 100644 --- a/documentdb_tests/pytest.ini +++ b/documentdb_tests/pytest.ini @@ -50,6 +50,7 @@ markers = # Timeout for tests (seconds) timeout = 300 +timeout_method = thread # Parallel execution settings # Use with: pytest -n auto From 77779317aa0cb9582c8ac79122f015bba25caa42 Mon Sep 17 00:00:00 2001 From: "Paterson.How" <71473631+PatersonProjects@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:42:49 -0700 Subject: [PATCH 13/35] Add $dbStats tests (#622) Signed-off-by: PatersonProjects Signed-off-by: RyanGarfinkel <113050972+RyanGarfinkel@users.noreply.github.com> --- .../diagnostic/commands/dbStats/__init__.py | 0 .../commands/dbStats/test_dbStats_accuracy.py | 109 ++++++++ .../dbStats/test_dbStats_argument_handling.py | 238 ++++++++++++++++++ .../test_dbStats_collection_scenarios.py | 69 +++++ .../dbStats/test_dbStats_core_behavior.py | 78 ++++++ .../commands/dbStats/test_dbStats_errors.py | 65 +++++ .../test_dbStats_response_structure.py | 169 +++++++++++++ 7 files changed, 728 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/dbStats/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/dbStats/test_dbStats_accuracy.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/dbStats/test_dbStats_argument_handling.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/dbStats/test_dbStats_collection_scenarios.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/dbStats/test_dbStats_core_behavior.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/dbStats/test_dbStats_errors.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/dbStats/test_dbStats_response_structure.py diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/dbStats/__init__.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/dbStats/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/dbStats/test_dbStats_accuracy.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/dbStats/test_dbStats_accuracy.py new file mode 100644 index 000000000..b8be07716 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/dbStats/test_dbStats_accuracy.py @@ -0,0 +1,109 @@ +"""Tests for dbStats accuracy and state changes. + +Covers count fields (collections, objects, indexes) reflecting database +state. +""" + +import pytest +from bson import Int64 + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, +) +from documentdb_tests.framework.assertions import assertProperties, assertSuccess +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq + +pytestmark = pytest.mark.admin + + +COUNT_ACCURACY_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + id="collections_count_reflects_created", + setup=[{"create": "c1"}, {"create": "c2"}, {"create": "c3"}], + command={"dbStats": 1}, + use_admin=False, + checks={"collections": Eq(Int64(3))}, + msg="collections should equal the number of created collections", + ), + DiagnosticTestCase( + id="objects_sum_across_collections", + setup=[ + {"insert": "c1", "documents": [{"_id": i} for i in range(4)]}, + {"insert": "c2", "documents": [{"_id": i} for i in range(6)]}, + ], + command={"dbStats": 1}, + use_admin=False, + checks={"objects": Eq(Int64(10))}, + msg="objects should equal the total documents across all collections", + ), + DiagnosticTestCase( + id="indexes_default_plus_created", + setup=[ + {"insert": "c1", "documents": [{"_id": i, "a": i, "b": i} for i in range(5)]}, + { + "createIndexes": "c1", + "indexes": [ + {"key": {"a": 1}, "name": "a_1"}, + {"key": {"b": 1}, "name": "b_1"}, + ], + }, + ], + command={"dbStats": 1}, + use_admin=False, + checks={"indexes": Eq(Int64(3))}, + msg="indexes should count the default _id index plus created indexes", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(COUNT_ACCURACY_TESTS)) +def test_dbStats_count_accuracy(collection, test): + """Test dbStats count fields accurately reflect created collections, documents, and indexes.""" + for setup_command in test.setup: + setup_result = execute_command(collection, setup_command) + if isinstance(setup_result, Exception): + raise setup_result + result = execute_command(collection, test.command) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) + + +def test_dbStats_scale_divides_data_size(collection): + """Test scale divides reported dataSize by the scale factor (approximately). + + Compares dataSize at scale 1 (bytes) against scale 1024 (KiB). The scaled + value should approximate the unscaled value divided by 1024; a tolerance + absorbs the server's integer truncation, avoiding flakiness. + """ + collection.insert_many([{"_id": i, "data": "x" * 1024} for i in range(50)]) + unscaled = execute_command(collection, {"dbStats": 1, "scale": 1}) + scaled = execute_command(collection, {"dbStats": 1, "scale": 1024}) + expected_scaled = unscaled.get("dataSize") / 1024 + actual_scaled = scaled.get("dataSize") + assertSuccess( + actual_scaled == pytest.approx(expected_scaled, abs=1.0), + expected=True, + raw_res=True, + msg=( + f"scale=1024 dataSize ({actual_scaled}) should approximate " + f"unscaled dataSize / 1024 ({expected_scaled})" + ), + ) + + +def test_dbStats_avg_obj_size_unaffected_by_scale(collection): + """Test avgObjSize is identical regardless of the scale value. + + avgObjSize is always reported in bytes and should not be divided + by the scale factor. + """ + collection.insert_many([{"_id": i, "data": "x" * 100} for i in range(10)]) + unscaled = execute_command(collection, {"dbStats": 1, "scale": 1}) + scaled = execute_command(collection, {"dbStats": 1, "scale": 1024}) + assertSuccess( + scaled.get("avgObjSize"), + expected=unscaled.get("avgObjSize"), + raw_res=True, + msg="avgObjSize should be identical regardless of scale", + ) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/dbStats/test_dbStats_argument_handling.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/dbStats/test_dbStats_argument_handling.py new file mode 100644 index 000000000..b99d73704 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/dbStats/test_dbStats_argument_handling.py @@ -0,0 +1,238 @@ +"""Tests for dbStats command argument handling. + +The value of the ``dbStats`` field is ignored by the server: any value +selects the current database, so every BSON type should be accepted, +including numeric edge cases such as 0, -1, and Infinity. + +Also covers the ``scale`` parameter (type-level acceptance and rejection, +value truncation, and duplicate-key behavior) and the ``freeStorage`` +parameter (type-level acceptance and rejection, free-storage field +presence, and omission when unset or 0). Value-level errors (BadValue) +are in test_dbStats_errors.py. +""" + +import pytest +from bson import SON, Decimal128, Int64 + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, +) +from documentdb_tests.framework.assertions import ( + assertFailureCode, + assertProperties, + assertSuccessPartial, +) +from documentdb_tests.framework.bson_type_validator import ( + BsonTypeTestCase, + generate_bson_acceptance_test_cases, + generate_bson_rejection_test_cases, +) +from documentdb_tests.framework.error_codes import TYPE_MISMATCH_ERROR +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, Exists, NotExists +from documentdb_tests.framework.test_constants import FLOAT_INFINITY, BsonType + +pytestmark = pytest.mark.admin + + +DBSTATS_VALUE_PARAMS: list[BsonTypeTestCase] = [ + BsonTypeTestCase( + id="dbStats_value", + msg="dbStats should accept all BSON types for the command field value", + keyword="dbStats", + valid_types=list(BsonType), + ), +] + +VALUE_ACCEPTANCE_CASES = generate_bson_acceptance_test_cases(DBSTATS_VALUE_PARAMS) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", VALUE_ACCEPTANCE_CASES) +def test_dbStats_accepts_any_value_type(collection, bson_type, sample_value, spec): + """Test dbStats accepts all BSON types for the command field value.""" + result = execute_command(collection, {"dbStats": sample_value}) + assertSuccessPartial( + result, + {"ok": 1.0, "db": collection.database.name}, + msg=f"dbStats should accept {bson_type.value} for the command field value", + ) + + +EDGE_CASE_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + id="value_zero", + command={"dbStats": 0}, + checks={"ok": Eq(1.0)}, + msg="dbStats:0 should succeed", + ), + DiagnosticTestCase( + id="value_negative_one", + command={"dbStats": -1}, + checks={"ok": Eq(1.0)}, + msg="dbStats:-1 should succeed", + ), + DiagnosticTestCase( + id="value_infinity", + command={"dbStats": FLOAT_INFINITY}, + checks={"ok": Eq(1.0)}, + msg="dbStats:Infinity should succeed", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(EDGE_CASE_TESTS)) +def test_dbStats_accepts_value_edge_cases(collection, test): + """Test dbStats succeeds for specific numeric edge-case command values.""" + result = execute_command(collection, test.command) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) + + +SCALE_TYPE_PARAMS: list[BsonTypeTestCase] = [ + BsonTypeTestCase( + id="scale", + msg="scale should reject non-numeric types with TypeMismatch", + keyword="scale", + valid_types=[BsonType.DOUBLE, BsonType.INT, BsonType.LONG, BsonType.DECIMAL, BsonType.NULL], + default_error_code=TYPE_MISMATCH_ERROR, + valid_inputs={BsonType.DECIMAL: Decimal128("1024"), BsonType.LONG: Int64(1024)}, + ), +] + +SCALE_REJECTION_CASES = generate_bson_rejection_test_cases(SCALE_TYPE_PARAMS) +SCALE_ACCEPTANCE_CASES = generate_bson_acceptance_test_cases(SCALE_TYPE_PARAMS) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", SCALE_ACCEPTANCE_CASES) +def test_dbStats_scale_accepts_valid_type(collection, bson_type, sample_value, spec): + """Test dbStats accepts valid BSON types for the scale parameter.""" + result = execute_command(collection, {"dbStats": 1, "scale": sample_value}) + assertSuccessPartial(result, {"ok": 1.0}, msg=spec.msg) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", SCALE_REJECTION_CASES) +def test_dbStats_scale_rejects_invalid_type(collection, bson_type, sample_value, spec): + """Test dbStats rejects non-numeric BSON types for the scale parameter with TypeMismatch.""" + result = execute_command(collection, {"dbStats": 1, "scale": sample_value}) + assertFailureCode(result, spec.expected_code(bson_type), msg=spec.msg) + + +SCALE_EDGE_CASES: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "double_truncates", + command={"dbStats": 1, "scale": 2.5}, + checks={"ok": Eq(1.0), "scaleFactor": Eq(Int64(2))}, + msg="Double scale should truncate toward zero", + ), + DiagnosticTestCase( + "double_1023_999_truncates", + command={"dbStats": 1, "scale": 1023.999}, + checks={"ok": Eq(1.0), "scaleFactor": Eq(Int64(1023))}, + msg="Double scale 1023.999 should truncate to 1023", + ), + DiagnosticTestCase( + "default_no_scale", + command={"dbStats": 1}, + checks={"ok": Eq(1.0), "scaleFactor": Eq(Int64(1))}, + msg="Omitting scale should default scaleFactor to 1", + ), + DiagnosticTestCase( + "duplicate_keys_last_valid", + command=SON([("dbStats", 1), ("scale", 1), ("scale", 1024)]), + checks={"ok": Eq(1.0), "scaleFactor": Eq(Int64(1024))}, + msg="Last duplicate scale value should win", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(SCALE_EDGE_CASES)) +def test_dbStats_scale_edge_cases(collection, test): + """Test dbStats scale truncation and default behaviour.""" + result = execute_command(collection, test.command) + assertProperties(result, test.checks, raw_res=True, msg=test.msg) + + +FREE_STORAGE_TYPE_PARAMS: list[BsonTypeTestCase] = [ + BsonTypeTestCase( + id="freeStorage", + msg="freeStorage should reject non-numeric, non-bool types with TypeMismatch", + keyword="freeStorage", + valid_types=[ + BsonType.BOOL, + BsonType.DOUBLE, + BsonType.INT, + BsonType.LONG, + BsonType.DECIMAL, + BsonType.NULL, + ], + default_error_code=TYPE_MISMATCH_ERROR, + ), +] + +FREE_STORAGE_REJECTION_CASES = generate_bson_rejection_test_cases(FREE_STORAGE_TYPE_PARAMS) +FREE_STORAGE_ACCEPTANCE_CASES = generate_bson_acceptance_test_cases(FREE_STORAGE_TYPE_PARAMS) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", FREE_STORAGE_ACCEPTANCE_CASES) +def test_dbStats_free_storage_accepts_valid_type(collection, bson_type, sample_value, spec): + """Test dbStats accepts valid BSON types for the freeStorage parameter.""" + result = execute_command(collection, {"dbStats": 1, "freeStorage": sample_value}) + assertSuccessPartial(result, {"ok": 1.0}, msg=spec.msg) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", FREE_STORAGE_REJECTION_CASES) +def test_dbStats_free_storage_rejects_invalid_type(collection, bson_type, sample_value, spec): + """Test dbStats rejects non-numeric, non-bool BSON types for freeStorage with TypeMismatch.""" + result = execute_command(collection, {"dbStats": 1, "freeStorage": sample_value}) + assertFailureCode(result, spec.expected_code(bson_type), msg=spec.msg) + + +FREE_STORAGE_FIELD_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "free_storage_one_includes_fields", + setup=[ + {"insert": "c1", "documents": [{"_id": 1}]}, + {"createIndexes": "c1", "indexes": [{"key": {"a": 1}, "name": "a_1"}]}, + ], + command={"dbStats": 1, "freeStorage": 1}, + checks={ + "freeStorageSize": Exists(), + "indexFreeStorageSize": Exists(), + "totalFreeStorageSize": Exists(), + }, + msg="freeStorage:1 should include free-storage fields", + ), + DiagnosticTestCase( + "no_free_storage_param", + setup=[{"insert": "c1", "documents": [{"_id": 1}]}], + command={"dbStats": 1}, + checks={ + "freeStorageSize": NotExists(), + "indexFreeStorageSize": NotExists(), + "totalFreeStorageSize": NotExists(), + }, + msg="Omitting freeStorage should omit free-storage fields", + ), + DiagnosticTestCase( + "free_storage_zero", + setup=[{"insert": "c1", "documents": [{"_id": 1}]}], + command={"dbStats": 1, "freeStorage": 0}, + checks={ + "freeStorageSize": NotExists(), + "indexFreeStorageSize": NotExists(), + "totalFreeStorageSize": NotExists(), + }, + msg="freeStorage:0 should omit free-storage fields", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(FREE_STORAGE_FIELD_TESTS)) +def test_dbStats_free_storage_fields(collection, test): + """Test dbStats free-storage field presence based on the freeStorage option.""" + for setup_command in test.setup: + setup_result = execute_command(collection, setup_command) + if isinstance(setup_result, Exception): + raise setup_result + result = execute_command(collection, test.command) + assertProperties(result, test.checks, raw_res=True, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/dbStats/test_dbStats_collection_scenarios.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/dbStats/test_dbStats_collection_scenarios.py new file mode 100644 index 000000000..e631c837e --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/dbStats/test_dbStats_collection_scenarios.py @@ -0,0 +1,69 @@ +"""Tests for dbStats across collection variants and data scenarios. + +Covers empty collections (with and without a secondary index), avgObjSize +when there are no objects, index counts across multiple collections, and +capped collections. +""" + +import pytest +from bson import Int64 + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, +) +from documentdb_tests.framework.assertions import assertProperties +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq + +pytestmark = pytest.mark.admin + + +COLLECTION_SCENARIO_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + id="empty_collection_index_count", + setup=[{"createIndexes": "c1", "indexes": [{"key": {"a": 1}, "name": "a_1"}]}], + command={"dbStats": 1}, + checks={"indexes": Eq(Int64(2))}, + msg="Empty collection with one secondary index should report indexes:2", + ), + DiagnosticTestCase( + id="avg_obj_size_zero_when_no_objects", + command={"dbStats": 1}, + checks={"objects": Eq(Int64(0)), "avgObjSize": Eq(0.0)}, + msg="Empty database should report objects:0 and avgObjSize:0", + ), + DiagnosticTestCase( + id="indexes_across_collections", + setup=[ + {"insert": "c1", "documents": [{"_id": i, "a": i} for i in range(5)]}, + {"createIndexes": "c1", "indexes": [{"key": {"a": 1}, "name": "a_1"}]}, + {"insert": "c2", "documents": [{"_id": i, "b": i} for i in range(5)]}, + {"createIndexes": "c2", "indexes": [{"key": {"b": 1}, "name": "b_1"}]}, + ], + command={"dbStats": 1}, + checks={"indexes": Eq(Int64(4))}, + msg="indexes should total default plus secondary indexes across collections", + ), + DiagnosticTestCase( + id="capped_collection_counted", + setup=[ + {"create": "c1", "capped": True, "size": 4096}, + {"insert": "c1", "documents": [{"_id": i} for i in range(3)]}, + ], + command={"dbStats": 1}, + checks={"collections": Eq(Int64(1)), "objects": Eq(Int64(3))}, + msg="Capped collection should be counted with its documents", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(COLLECTION_SCENARIO_TESTS)) +def test_dbStats_collection_scenarios(collection, test): + """Test dbStats database-level counts and sizes across collection variants and data shapes.""" + for setup_command in test.setup: + setup_result = execute_command(collection, setup_command) + if isinstance(setup_result, Exception): + raise setup_result + result = execute_command(collection, test.command) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/dbStats/test_dbStats_core_behavior.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/dbStats/test_dbStats_core_behavior.py new file mode 100644 index 000000000..3d75e21e6 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/dbStats/test_dbStats_core_behavior.py @@ -0,0 +1,78 @@ +"""Tests for dbStats command core behavior. + +Covers success on populated and empty databases, the all-zero response for +a non-existent database, and execution against the admin database. +Command-level errors are in test_dbStats_errors.py. +""" + +import pytest +from bson import Int64 + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, +) +from documentdb_tests.framework.assertions import assertProperties, assertSuccessPartial +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, IsType +from documentdb_tests.framework.target_collection import TargetDatabase + +pytestmark = pytest.mark.admin + + +SUCCESS_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + id="populated_database", + setup=[{"insert": "c1", "documents": [{"_id": 0, "a": 1}, {"_id": 1, "a": 2}]}], + command={"dbStats": 1}, + use_admin=False, + checks={"ok": Eq(1.0), "db": IsType("string")}, + msg="Populated database should return ok:1", + ), + DiagnosticTestCase( + id="empty_database", + command={"dbStats": 1}, + use_admin=False, + checks={"ok": Eq(1.0), "db": IsType("string"), "collections": Eq(Int64(0))}, + msg="Empty database should return ok:1 with zero collections", + ), + DiagnosticTestCase( + id="admin_database", + command={"dbStats": 1}, + use_admin=True, + checks={"ok": Eq(1.0), "db": Eq("admin")}, + msg="dbStats on admin database should report db:admin", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(SUCCESS_TESTS)) +def test_dbStats_core_behavior(collection, test): + """Test dbStats succeeds and reports expected top-level fields across databases.""" + for setup_command in test.setup: + setup_result = execute_command(collection, setup_command) + if isinstance(setup_result, Exception): + raise setup_result + executor = execute_admin_command if test.use_admin else execute_command + result = executor(collection, test.command) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) + + +def test_dbStats_nonexistent_database_returns_zeros(collection, register_db_cleanup): + """Test dbStats on a non-existent database returns zeroed size and count fields.""" + missing_coll = TargetDatabase(suffix="missing").resolve(collection.database, collection) + register_db_cleanup(missing_coll.database.name) + result = execute_command(missing_coll, {"dbStats": 1}) + assertSuccessPartial( + result, + { + "ok": 1.0, + "db": missing_coll.database.name, + "collections": Int64(0), + "objects": Int64(0), + "storageSize": 0.0, + "indexes": Int64(0), + "indexSize": 0.0, + }, + msg="Non-existent database should report all counts and sizes as zero", + ) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/dbStats/test_dbStats_errors.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/dbStats/test_dbStats_errors.py new file mode 100644 index 000000000..05beb2174 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/dbStats/test_dbStats_errors.py @@ -0,0 +1,65 @@ +"""Tests for dbStats command error conditions. + +Covers value-level errors (BadValue for invalid scale values) and +command-level errors (unrecognized fields). Type-level rejections +(TypeMismatch for invalid scale types) are in test_dbStats_argument_handling.py. +""" + +import pytest +from bson import SON + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import BAD_VALUE_ERROR, UNRECOGNIZED_COMMAND_FIELD_ERROR +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = pytest.mark.admin + + +INVALID_SCALE_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "zero", + command={"dbStats": 1, "scale": 0}, + error_code=BAD_VALUE_ERROR, + msg="scale=0 should error with BadValue", + ), + DiagnosticTestCase( + "negative_int", + command={"dbStats": 1, "scale": -1}, + error_code=BAD_VALUE_ERROR, + msg="Negative int scale should error with BadValue", + ), + DiagnosticTestCase( + "fractional_lt_1", + command={"dbStats": 1, "scale": 0.5}, + error_code=BAD_VALUE_ERROR, + msg="Fractional scale < 1 should error with BadValue", + ), + DiagnosticTestCase( + "duplicate_keys_last_invalid", + command=SON([("dbStats", 1), ("scale", 1024), ("scale", -1)]), + error_code=BAD_VALUE_ERROR, + msg="Invalid last duplicate scale value should error", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(INVALID_SCALE_TESTS)) +def test_dbStats_invalid_scale_errors(collection, test): + """Test dbStats rejects invalid (non-positive/truncate-to-zero) scale values with BadValue.""" + result = execute_command(collection, test.command) + assertFailureCode(result, test.error_code, msg=test.msg) + + +def test_dbStats_unrecognized_field_errors(collection): + """Test dbStats rejects an unrecognized command field.""" + collection.insert_one({"_id": 1}) + result = execute_command(collection, {"dbStats": 1, "bogusField": 1}) + assertFailureCode( + result, + UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="Unrecognized command field should error with code 40415", + ) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/dbStats/test_dbStats_response_structure.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/dbStats/test_dbStats_response_structure.py new file mode 100644 index 000000000..25272aa74 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/dbStats/test_dbStats_response_structure.py @@ -0,0 +1,169 @@ +"""Tests for the dbStats response structure. + +Covers the presence and BSON type of every documented response field, the +totalSize relationship, dataSize positivity after inserts, the avgObjSize +relationship, and collection/view counts. +""" + +import pytest +from bson import Int64 + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, +) +from documentdb_tests.framework.assertions import assertProperties +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, Gt, IsType + +pytestmark = pytest.mark.admin + + +RESPONSE_PROPERTY_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + id="db_is_string", + checks={"db": IsType("string")}, + msg="'db' field should be a string", + ), + DiagnosticTestCase( + id="collections_is_long", + checks={"collections": IsType("long")}, + msg="'collections' field should be a long", + ), + DiagnosticTestCase( + id="views_is_long", + checks={"views": IsType("long")}, + msg="'views' field should be a long", + ), + DiagnosticTestCase( + id="objects_is_long", + checks={"objects": IsType("long")}, + msg="'objects' field should be a long", + ), + DiagnosticTestCase( + id="avgObjSize_is_double", + checks={"avgObjSize": IsType("double")}, + msg="'avgObjSize' field should be a double", + ), + DiagnosticTestCase( + id="dataSize_is_double", + checks={"dataSize": IsType("double")}, + msg="'dataSize' field should be a double", + ), + DiagnosticTestCase( + id="storageSize_is_double", + checks={"storageSize": IsType("double")}, + msg="'storageSize' field should be a double", + ), + DiagnosticTestCase( + id="indexes_is_long", + checks={"indexes": IsType("long")}, + msg="'indexes' field should be a long", + ), + DiagnosticTestCase( + id="indexSize_is_double", + checks={"indexSize": IsType("double")}, + msg="'indexSize' field should be a double", + ), + DiagnosticTestCase( + id="totalSize_is_double", + checks={"totalSize": IsType("double")}, + msg="'totalSize' field should be a double", + ), + DiagnosticTestCase( + id="scaleFactor_is_long", + checks={"scaleFactor": IsType("long")}, + msg="'scaleFactor' field should be a long", + ), + DiagnosticTestCase( + id="fsUsedSize_is_double", + checks={"fsUsedSize": IsType("double")}, + msg="'fsUsedSize' field should be a double", + ), + DiagnosticTestCase( + id="fsTotalSize_is_double", + checks={"fsTotalSize": IsType("double")}, + msg="'fsTotalSize' field should be a double", + ), + DiagnosticTestCase( + id="ok_is_double", + checks={"ok": IsType("double")}, + msg="'ok' field should be a double", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(RESPONSE_PROPERTY_TESTS)) +def test_dbStats_response_properties(collection, test): + """Verifies each documented dbStats response field has the expected BSON type.""" + collection.insert_many([{"_id": i, "a": i} for i in range(5)]) + collection.create_index("a") + result = execute_command(collection, {"dbStats": 1}) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) + + +def test_dbStats_total_size_relationship(collection): + """Test totalSize equals storageSize plus indexSize.""" + collection.insert_many([{"_id": i, "a": i} for i in range(20)]) + collection.create_index("a") + result = execute_command(collection, {"dbStats": 1}) + assertProperties( + result, + {"totalSize": Eq(result.get("storageSize") + result.get("indexSize"))}, + raw_res=True, + msg="totalSize should equal storageSize + indexSize", + ) + + +def test_dbStats_avg_obj_size_equals_data_size_over_objects(collection): + """Test avgObjSize equals dataSize divided by objects.""" + collection.insert_many([{"_id": i, "data": "x" * (i + 1)} for i in range(10)]) + result = execute_command(collection, {"dbStats": 1}) + assertProperties( + result, + {"avgObjSize": Eq(result.get("dataSize") / result.get("objects"))}, + raw_res=True, + msg="avgObjSize should equal dataSize / objects", + ) + + +RESPONSE_STATE_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + id="data_size_positive_after_insert", + setup=[{"insert": "c1", "documents": [{"_id": i, "data": "x" * 50} for i in range(10)]}], + command={"dbStats": 1}, + checks={"dataSize": Gt(0.0)}, + msg="dataSize should be positive after inserts", + ), + DiagnosticTestCase( + id="collections_count_includes_system_views", + setup=[ + {"insert": "c1", "documents": [{"_id": i} for i in range(3)]}, + {"create": "c1_view", "viewOn": "c1", "pipeline": []}, + ], + command={"dbStats": 1}, + checks={"collections": Eq(Int64(2))}, + msg="collections should include the base collection and system.views", + ), + DiagnosticTestCase( + id="views_count", + setup=[ + {"insert": "c1", "documents": [{"_id": i} for i in range(3)]}, + {"create": "c1_view", "viewOn": "c1", "pipeline": []}, + ], + command={"dbStats": 1}, + checks={"views": Eq(Int64(1))}, + msg="views should count the created view", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(RESPONSE_STATE_TESTS)) +def test_dbStats_response_reflects_state(collection, test): + """Test dbStats response fields reflect database state from setup commands.""" + for setup_command in test.setup: + setup_result = execute_command(collection, setup_command) + if isinstance(setup_result, Exception): + raise setup_result + result = execute_command(collection, test.command) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) From 879e2d9b077d2c0cfc3ba8d46e6e4bd40a069b20 Mon Sep 17 00:00:00 2001 From: Daniel Frankcom Date: Tue, 7 Jul 2026 13:12:54 -0700 Subject: [PATCH 14/35] Add $exp expression tests (#663) Signed-off-by: Daniel Frankcom Signed-off-by: RyanGarfinkel <113050972+RyanGarfinkel@users.noreply.github.com> --- .../arithmetic/exp/test_exp_boundaries.py | 166 ++++++++++++++++++ .../arithmetic/exp/test_exp_errors.py | 76 ++++++++ .../arithmetic/exp/test_exp_input_forms.py | 97 ++++++++++ .../arithmetic/exp/test_exp_magnitude.py | 140 +++++++++++++++ .../arithmetic/exp/test_exp_non_finite.py | 86 +++++++++ .../arithmetic/exp/test_exp_null.py | 45 +++++ .../arithmetic/exp/test_exp_return_type.py | 58 ++++++ 7 files changed, 668 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/exp/test_exp_boundaries.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/exp/test_exp_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/exp/test_exp_input_forms.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/exp/test_exp_magnitude.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/exp/test_exp_non_finite.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/exp/test_exp_null.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/exp/test_exp_return_type.py diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/exp/test_exp_boundaries.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/exp/test_exp_boundaries.py new file mode 100644 index 000000000..f472fcd13 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/exp/test_exp_boundaries.py @@ -0,0 +1,166 @@ +"""Tests for $exp at representable-range boundaries, including overflow and underflow.""" + +import pytest +from bson import Decimal128 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_LARGE_EXPONENT, + DECIMAL128_MAX, + DECIMAL128_MIN, + DECIMAL128_SMALL_EXPONENT, + DOUBLE_MIN_NEGATIVE_SUBNORMAL, + DOUBLE_MIN_SUBNORMAL, + DOUBLE_NEAR_MAX, + DOUBLE_NEAR_MIN, + DOUBLE_ZERO, + FLOAT_INFINITY, + INT32_MAX, + INT64_MAX, + INT64_MIN, +) + +# Property [Overflow]: $exp overflows to infinity past the largest representable exponent and +# underflows to zero for large negative inputs. +EXP_OVERFLOW_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "just_below_overflow", + doc={"value": 709}, + expression={"$exp": ["$value"]}, + expected=pytest.approx(8.218407461554972e307), + msg="$exp should return a large finite double just below the overflow boundary", + ), + ExpressionTestCase( + "overflow", + doc={"value": 710}, + expression={"$exp": ["$value"]}, + expected=FLOAT_INFINITY, + msg="$exp should return infinity past the overflow boundary", + ), + ExpressionTestCase( + "underflow", + doc={"value": -750}, + expression={"$exp": ["$value"]}, + expected=DOUBLE_ZERO, + msg="$exp should underflow to zero for a large negative input", + ), +] + +# Property [Integer Boundaries]: $exp of extreme integer inputs overflows to infinity or +# underflows to zero. +EXP_INTEGER_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int32_max", + doc={"value": INT32_MAX}, + expression={"$exp": ["$value"]}, + expected=FLOAT_INFINITY, + msg="$exp should return infinity for INT32_MAX", + ), + ExpressionTestCase( + "int64_max", + doc={"value": INT64_MAX}, + expression={"$exp": ["$value"]}, + expected=FLOAT_INFINITY, + msg="$exp should return infinity for INT64_MAX", + ), + ExpressionTestCase( + "int64_min", + doc={"value": INT64_MIN}, + expression={"$exp": ["$value"]}, + expected=DOUBLE_ZERO, + msg="$exp should underflow to zero for INT64_MIN", + ), +] + +# Property [Double Boundaries]: $exp near the double subnormal range returns one, and a large +# double overflows to infinity. +EXP_DOUBLE_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "double_min_subnormal", + doc={"value": DOUBLE_MIN_SUBNORMAL}, + expression={"$exp": ["$value"]}, + expected=1.0, + msg="$exp should return one for the minimum subnormal double", + ), + ExpressionTestCase( + "double_min_negative_subnormal", + doc={"value": DOUBLE_MIN_NEGATIVE_SUBNORMAL}, + expression={"$exp": ["$value"]}, + expected=1.0, + msg="$exp should return one for the minimum negative subnormal double", + ), + ExpressionTestCase( + "double_near_min", + doc={"value": DOUBLE_NEAR_MIN}, + expression={"$exp": ["$value"]}, + expected=1.0, + msg="$exp should return one for a near-zero positive double", + ), + ExpressionTestCase( + "double_near_max", + doc={"value": DOUBLE_NEAR_MAX}, + expression={"$exp": ["$value"]}, + expected=FLOAT_INFINITY, + msg="$exp should overflow to infinity for a large double", + ), +] + +# Property [Decimal128 Boundaries]: $exp of extreme decimal128 exponents overflows to infinity or +# underflows to zero, preserving the decimal type. +EXP_DECIMAL_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "decimal_small_exponent", + doc={"value": DECIMAL128_SMALL_EXPONENT}, + expression={"$exp": ["$value"]}, + expected=Decimal128("1"), + msg="$exp should return one for a decimal128 with a small exponent", + ), + ExpressionTestCase( + "decimal_large_exponent", + doc={"value": DECIMAL128_LARGE_EXPONENT}, + expression={"$exp": ["$value"]}, + expected=DECIMAL128_INFINITY, + msg="$exp should overflow to infinity for a decimal128 with a large exponent", + ), + ExpressionTestCase( + "decimal_max", + doc={"value": DECIMAL128_MAX}, + expression={"$exp": ["$value"]}, + expected=DECIMAL128_INFINITY, + msg="$exp should overflow to infinity for decimal128 max", + ), + ExpressionTestCase( + "decimal_min", + doc={"value": DECIMAL128_MIN}, + expression={"$exp": ["$value"]}, + expected=Decimal128("0E-6176"), + msg="$exp should underflow to zero for decimal128 min", + ), +] + +EXP_BOUNDARY_ALL_TESTS = ( + EXP_OVERFLOW_TESTS + + EXP_INTEGER_BOUNDARY_TESTS + + EXP_DOUBLE_BOUNDARY_TESTS + + EXP_DECIMAL_BOUNDARY_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(EXP_BOUNDARY_ALL_TESTS)) +def test_exp_boundaries(collection, test_case: ExpressionTestCase): + """Test $exp representable-range boundary cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/exp/test_exp_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/exp/test_exp_errors.py new file mode 100644 index 000000000..00aebadd6 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/exp/test_exp_errors.py @@ -0,0 +1,76 @@ +"""Tests for $exp type and arity errors.""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + EXPRESSION_TYPE_MISMATCH_ERROR, + NON_NUMERIC_TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Type Strictness]: $exp rejects non-numeric input types. +EXP_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"type_{tid}", + doc={"value": val}, + expression={"$exp": ["$value"]}, + error_code=NON_NUMERIC_TYPE_MISMATCH_ERROR, + msg=f"$exp should reject a {tid} input", + ) + for tid, val in [ + ("string", "abc"), + ("bool", True), + ("array", [1, 2]), + ("object", {"a": 1}), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("regex", Regex("abc")), + ("binary", Binary(b"data")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("timestamp", Timestamp(1, 1)), + ("code", Code("function(){}")), + ] +] + +# Property [Arity]: $exp requires exactly one argument. +EXP_ARITY_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "arity_zero", + doc={}, + expression={"$exp": []}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="$exp should reject zero arguments", + ), + ExpressionTestCase( + "arity_two", + doc={}, + expression={"$exp": [1, 2]}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="$exp should reject two arguments", + ), +] + +EXP_ERROR_ALL_TESTS = EXP_TYPE_ERROR_TESTS + EXP_ARITY_ERROR_TESTS + + +@pytest.mark.parametrize("test_case", pytest_params(EXP_ERROR_ALL_TESTS)) +def test_exp_errors(collection, test_case: ExpressionTestCase): + """Test $exp type and arity error cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/exp/test_exp_input_forms.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/exp/test_exp_input_forms.py new file mode 100644 index 000000000..729d7713b --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/exp/test_exp_input_forms.py @@ -0,0 +1,97 @@ +"""Tests for $exp argument forms, literal input, field paths, and nested expression input.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import NON_NUMERIC_TYPE_MISMATCH_ERROR +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Argument Form]: $exp accepts its single argument bare or wrapped in a one-element +# array. +EXP_ARGUMENT_FORM_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "form_array", + doc={"value": 1}, + expression={"$exp": ["$value"]}, + expected=pytest.approx(2.718281828459045), + msg="$exp should accept its argument wrapped in a one-element array", + ), + ExpressionTestCase( + "form_bare", + doc={"value": 1}, + expression={"$exp": "$value"}, + expected=pytest.approx(2.718281828459045), + msg="$exp should accept its argument without an array wrapper", + ), +] + +# Property [Literal Input]: $exp evaluates an inline literal argument, not only document fields. +EXP_LITERAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "literal_input", + doc={}, + expression={"$exp": [1]}, + expected=pytest.approx(2.718281828459045), + msg="$exp should return e for an inline literal argument", + ), +] + +# Property [Expression Input]: $exp evaluates a nested expression argument before exponentiating. +EXP_EXPRESSION_INPUT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_exp", + doc={}, + expression={"$exp": {"$exp": 1}}, + expected=pytest.approx(15.154262241479262), + msg="$exp should evaluate a nested $exp expression argument", + ), +] + +# Property [Field Path Input]: $exp resolves a field path argument. A dotted path into a nested +# object yields the referenced value; a path over an array (an array-of-objects path, or a numeric +# component applied over an array) resolves to an array, which $exp rejects as a non-numeric type. +EXP_FIELD_PATH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_field_path", + doc={"a": {"b": 1}}, + expression={"$exp": "$a.b"}, + expected=pytest.approx(2.718281828459045), + msg="$exp should resolve a dotted field path into a nested object", + ), + ExpressionTestCase( + "composite_array_field_path", + doc={"a": [{"b": 1}, {"b": 2}]}, + expression={"$exp": "$a.b"}, + error_code=NON_NUMERIC_TYPE_MISMATCH_ERROR, + msg="$exp should reject a field path that resolves to an array from an array of objects", + ), + ExpressionTestCase( + "array_index_field_path", + doc={"a": [1, 2]}, + expression={"$exp": "$a.0"}, + error_code=NON_NUMERIC_TYPE_MISMATCH_ERROR, + msg="$exp should reject a numeric path component over an array, which resolves non-numeric", + ), +] + +EXP_INPUT_FORM_TESTS = ( + EXP_ARGUMENT_FORM_TESTS + EXP_LITERAL_TESTS + EXP_EXPRESSION_INPUT_TESTS + EXP_FIELD_PATH_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(EXP_INPUT_FORM_TESTS)) +def test_exp_input_forms(collection, test_case: ExpressionTestCase): + """Test $exp argument form, literal, field path, and nested expression input cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/exp/test_exp_magnitude.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/exp/test_exp_magnitude.py new file mode 100644 index 000000000..204537099 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/exp/test_exp_magnitude.py @@ -0,0 +1,140 @@ +"""Tests for $exp core exponentiation values, including signed zero.""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ZERO, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_ZERO, + INT64_ZERO, +) + +# Property [Exponent Value]: $exp returns e raised to the input power. +EXP_VALUE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "positive_int32", + doc={"value": 1}, + expression={"$exp": ["$value"]}, + expected=pytest.approx(2.718281828459045), + msg="$exp should return e for int32 one", + ), + ExpressionTestCase( + "positive_int64", + doc={"value": Int64(1)}, + expression={"$exp": ["$value"]}, + expected=pytest.approx(2.718281828459045), + msg="$exp should return e for int64 one", + ), + ExpressionTestCase( + "positive_double", + doc={"value": 1.5}, + expression={"$exp": ["$value"]}, + expected=pytest.approx(4.4816890703380645), + msg="$exp should return e raised to 1.5 for double 1.5", + ), + ExpressionTestCase( + "positive_decimal", + doc={"value": Decimal128("1")}, + expression={"$exp": ["$value"]}, + expected=Decimal128("2.718281828459045235360287471352662"), + msg="$exp should return e for decimal128 one", + ), + ExpressionTestCase( + "negative_int32", + doc={"value": -1}, + expression={"$exp": ["$value"]}, + expected=pytest.approx(0.36787944117144233), + msg="$exp should return e raised to -1 for int32 negative one", + ), + ExpressionTestCase( + "negative_int64", + doc={"value": Int64(-1)}, + expression={"$exp": ["$value"]}, + expected=pytest.approx(0.36787944117144233), + msg="$exp should return e raised to -1 for int64 negative one", + ), + ExpressionTestCase( + "negative_double", + doc={"value": -1.5}, + expression={"$exp": ["$value"]}, + expected=pytest.approx(0.22313016014842982), + msg="$exp should return e raised to -1.5 for double -1.5", + ), + ExpressionTestCase( + "negative_decimal", + doc={"value": Decimal128("-1")}, + expression={"$exp": ["$value"]}, + expected=Decimal128("0.3678794411714423215955237701614609"), + msg="$exp should return e raised to -1 for decimal128 negative one", + ), +] + +# Property [Zero]: $exp of any zero, including negative zero, returns one. +EXP_ZERO_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "zero_int32", + doc={"value": 0}, + expression={"$exp": ["$value"]}, + expected=1.0, + msg="$exp should return one for int32 zero", + ), + ExpressionTestCase( + "zero_int64", + doc={"value": INT64_ZERO}, + expression={"$exp": ["$value"]}, + expected=1.0, + msg="$exp should return one for int64 zero", + ), + ExpressionTestCase( + "zero_double", + doc={"value": DOUBLE_ZERO}, + expression={"$exp": ["$value"]}, + expected=1.0, + msg="$exp should return one for double zero", + ), + ExpressionTestCase( + "negative_zero_double", + doc={"value": DOUBLE_NEGATIVE_ZERO}, + expression={"$exp": ["$value"]}, + expected=1.0, + msg="$exp should return one for negative zero double", + ), + ExpressionTestCase( + "zero_decimal", + doc={"value": DECIMAL128_ZERO}, + expression={"$exp": ["$value"]}, + expected=Decimal128("1"), + msg="$exp should return one for decimal128 zero", + ), + ExpressionTestCase( + "negative_zero_decimal", + doc={"value": DECIMAL128_NEGATIVE_ZERO}, + expression={"$exp": ["$value"]}, + expected=Decimal128("1"), + msg="$exp should return one for negative zero decimal128", + ), +] + +EXP_MAGNITUDE_ALL_TESTS = EXP_VALUE_TESTS + EXP_ZERO_TESTS + + +@pytest.mark.parametrize("test_case", pytest_params(EXP_MAGNITUDE_ALL_TESTS)) +def test_exp_magnitude(collection, test_case: ExpressionTestCase): + """Test $exp exponentiation value and signed-zero cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/exp/test_exp_non_finite.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/exp/test_exp_non_finite.py new file mode 100644 index 000000000..c5cf04512 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/exp/test_exp_non_finite.py @@ -0,0 +1,86 @@ +"""Tests for $exp of infinity and NaN inputs.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_ZERO, + DOUBLE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, +) + +# Property [Infinity]: $exp of positive infinity is infinity and of negative infinity is zero. +EXP_INFINITY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "float_infinity", + doc={"value": FLOAT_INFINITY}, + expression={"$exp": ["$value"]}, + expected=FLOAT_INFINITY, + msg="$exp should return infinity for float infinity", + ), + ExpressionTestCase( + "float_negative_infinity", + doc={"value": FLOAT_NEGATIVE_INFINITY}, + expression={"$exp": ["$value"]}, + expected=DOUBLE_ZERO, + msg="$exp should return zero for float negative infinity", + ), + ExpressionTestCase( + "decimal128_infinity", + doc={"value": DECIMAL128_INFINITY}, + expression={"$exp": ["$value"]}, + expected=DECIMAL128_INFINITY, + msg="$exp should return decimal128 infinity for decimal128 infinity", + ), + ExpressionTestCase( + "decimal128_negative_infinity", + doc={"value": DECIMAL128_NEGATIVE_INFINITY}, + expression={"$exp": ["$value"]}, + expected=DECIMAL128_ZERO, + msg="$exp should return decimal128 zero for decimal128 negative infinity", + ), +] + +# Property [NaN]: $exp of NaN returns NaN of the same type. +EXP_NAN_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "float_nan", + doc={"value": FLOAT_NAN}, + expression={"$exp": ["$value"]}, + expected=pytest.approx(FLOAT_NAN, nan_ok=True), + msg="$exp should return NaN for float NaN", + ), + ExpressionTestCase( + "decimal128_nan", + doc={"value": DECIMAL128_NAN}, + expression={"$exp": ["$value"]}, + expected=DECIMAL128_NAN, + msg="$exp should return NaN for decimal128 NaN", + ), +] + +EXP_NON_FINITE_TESTS = EXP_INFINITY_TESTS + EXP_NAN_TESTS + + +@pytest.mark.parametrize("test_case", pytest_params(EXP_NON_FINITE_TESTS)) +def test_exp_non_finite(collection, test_case: ExpressionTestCase): + """Test $exp infinity and NaN cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/exp/test_exp_null.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/exp/test_exp_null.py new file mode 100644 index 000000000..180aea15f --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/exp/test_exp_null.py @@ -0,0 +1,45 @@ +"""Tests for $exp null and missing field propagation.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + MISSING, +) + +# Property [Null Propagation]: $exp of null or a missing field returns null. +EXP_NULL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_value", + doc={"value": None}, + expression={"$exp": ["$value"]}, + expected=None, + msg="$exp should return null for null input", + ), + ExpressionTestCase( + "missing_field", + doc={}, + expression={"$exp": [MISSING]}, + expected=None, + msg="$exp should return null for a missing field", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(EXP_NULL_TESTS)) +def test_exp_null(collection, test_case: ExpressionTestCase): + """Test $exp null and missing field propagation cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/exp/test_exp_return_type.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/exp/test_exp_return_type.py new file mode 100644 index 000000000..e54827dee --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/exp/test_exp_return_type.py @@ -0,0 +1,58 @@ +"""Tests for $exp return type across numeric input types.""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Return Type]: $exp returns double for int32, int64, and double inputs, and decimal for +# decimal128 inputs. +EXP_RETURN_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "return_type_int32", + doc={"value": 1}, + expression={"$type": {"$exp": "$value"}}, + expected="double", + msg="$exp should return a double for an int32 input", + ), + ExpressionTestCase( + "return_type_int64", + doc={"value": Int64(1)}, + expression={"$type": {"$exp": "$value"}}, + expected="double", + msg="$exp should return a double for an int64 input", + ), + ExpressionTestCase( + "return_type_double", + doc={"value": 1.0}, + expression={"$type": {"$exp": "$value"}}, + expected="double", + msg="$exp should return a double for a double input", + ), + ExpressionTestCase( + "return_type_decimal", + doc={"value": Decimal128("1")}, + expression={"$type": {"$exp": "$value"}}, + expected="decimal", + msg="$exp should return a decimal for a decimal128 input", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(EXP_RETURN_TYPE_TESTS)) +def test_exp_return_type(collection, test_case: ExpressionTestCase): + """Test $exp return type cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) From 050b7da111b5445f55b7b086530830173ef45892 Mon Sep 17 00:00:00 2001 From: Daniel Frankcom Date: Thu, 9 Jul 2026 12:28:46 -0700 Subject: [PATCH 15/35] Build large test payloads lazily (#670) Signed-off-by: Daniel Frankcom Signed-off-by: RyanGarfinkel <113050972+RyanGarfinkel@users.noreply.github.com> --- .github/workflows/pr-tests.yml | 30 ++++++++ .../aggregate/test_aggregate_allowdiskuse.py | 9 ++- .../test_aggregate_cursor_acceptance.py | 3 +- .../distinct/test_distinct_command_errors.py | 3 +- .../sum/test_accumulator_sum_boundary.py | 7 +- .../string/concat/test_concat_size_limit.py | 63 ++++++++++------- .../test_indexOfBytes_size_limit.py | 17 ++--- .../indexOfBytes/test_indexOfBytes_usage.py | 3 +- .../indexOfCP/test_indexOfCP_size_limit.py | 17 ++--- .../string/indexOfCP/test_indexOfCP_usage.py | 3 +- .../string/ltrim/test_ltrim_size_limit.py | 15 ++-- .../regexFind/test_regexFind_size_limit.py | 15 ++-- .../test_regexFindAll_matching.py | 9 ++- .../test_regexFindAll_size_limit.py | 21 +++--- .../regexMatch/test_regexMatch_size_limit.py | 11 +-- .../replaceAll/test_replaceAll_size_limit.py | 31 ++++---- .../replaceOne/test_replaceOne_size_limit.py | 55 ++++++++------- .../string/rtrim/test_rtrim_size_limit.py | 15 ++-- .../string/split/test_split_size_limit.py | 24 ++++--- .../test_strLenBytes_size_limit.py | 7 +- .../strLenCP/test_strLenCP_size_limit.py | 7 +- .../strcasecmp/test_strcasecmp_size_limit.py | 9 +-- .../test_substrBytes_size_limit.py | 13 ++-- .../substrCP/test_substrCP_size_limit.py | 9 +-- .../string/toLower/test_toLower_size_limit.py | 7 +- .../string/toUpper/test_toUpper_size_limit.py | 7 +- .../string/trim/test_trim_size_limit.py | 21 +++--- .../type/convert/test_convert_on_error.py | 3 +- .../type/convert/test_convert_size_limit.py | 23 +++--- .../core/operator/expressions/utils/utils.py | 9 +-- .../arrays/all/test_all_matching_behavior.py | 5 +- .../stages/count/test_count_behavior.py | 5 +- .../stages/lookup/test_lookup_result_size.py | 5 +- .../stages/lookup/utils/lookup_common.py | 5 +- .../stages/out/test_out_write_properties.py | 3 +- .../redact/test_redact_descend_structure.py | 9 ++- .../replaceRoot/test_replaceRoot_limits.py | 5 +- .../replaceWith/test_replaceWith_limits.py | 5 +- .../stages/test_stages_position_lookup.py | 5 +- .../operator/stages/unset/test_unset_paths.py | 11 +-- .../operator/stages/utils/stage_test_case.py | 3 +- .../test_listLocalSessions_success.py | 19 +++-- .../tests/core/utils/command_test_case.py | 3 +- documentdb_tests/conftest.py | 31 +++++++- documentdb_tests/framework/assertions.py | 3 + documentdb_tests/framework/executor.py | 6 +- .../framework/large_payload_guard.py | 53 ++++++++++++++ documentdb_tests/framework/lazy_payload.py | 70 +++++++++++++++++++ .../framework/target_collection.py | 4 +- 49 files changed, 498 insertions(+), 218 deletions(-) create mode 100644 documentdb_tests/framework/large_payload_guard.py create mode 100644 documentdb_tests/framework/lazy_payload.py diff --git a/.github/workflows/pr-tests.yml b/.github/workflows/pr-tests.yml index ec0404df2..e36b9bb94 100644 --- a/.github/workflows/pr-tests.yml +++ b/.github/workflows/pr-tests.yml @@ -53,6 +53,36 @@ jobs: - name: Run compatibility tests against ${{ matrix.target.name }} run: | + # Sample memory, load, and per-container usage to the live step log + # while the tests run. Writing to the live log means the samples + # survive even when the job is killed before it finishes, which is + # when a resource problem is most likely to show up. Every line begins + # with resource-monitor so the timeline can be read on its own by + # filtering the log for that word. + echo "resource-monitor samples follow. Each line shows the UTC time, host memory as used over total in megabytes, and swap in megabytes. It then shows the 1, 5, and 15 minute load average and the memory each container is using. Filter the log for resource-monitor to read them on their own." + sample_resources() { + set +e + while true; do + memory=$(free -m | awk '/^Mem:/{print $3"/"$2}') + swap=$(free -m | awk '/^Swap:/{print $3}') + load=$(cut -d' ' -f1-3 /proc/loadavg | tr ' ' ',') + containers=$(docker stats --no-stream --format '{{.Name}} {{.MemUsage}}' 2>/dev/null \ + | awk '{print $1"="$2}' | paste -sd' ' -) + echo "resource-monitor $(date -u +%H:%M:%S) memory=${memory}MB swap=${swap}MB load=${load} ${containers}" + sleep 10 + done + } + sample_resources & + # When the machine runs out of memory the kernel logs a line naming + # the process it kills. Follow the kernel log and forward that line so + # it lands in the step log, since it is the clearest evidence of an + # out of memory kill. The search terms are the exact phrases the + # kernel writes. + ( sudo -n dmesg --follow 2>/dev/null \ + | grep --line-buffered -iE 'out of memory|killed process|oom-kill' \ + | sed -u 's/^/resource-monitor out-of-memory /' ) & + trap 'kill %1 %2 2>/dev/null || true' EXIT + pytest documentdb_tests/compatibility/tests \ --connection-string "${{ matrix.target.connection_string }}" \ --engine-name "${{ matrix.target.engine }}" \ diff --git a/documentdb_tests/compatibility/tests/core/aggregation/commands/aggregate/test_aggregate_allowdiskuse.py b/documentdb_tests/compatibility/tests/core/aggregation/commands/aggregate/test_aggregate_allowdiskuse.py index 31aad9358..5bf6d22a4 100644 --- a/documentdb_tests/compatibility/tests/core/aggregation/commands/aggregate/test_aggregate_allowdiskuse.py +++ b/documentdb_tests/compatibility/tests/core/aggregation/commands/aggregate/test_aggregate_allowdiskuse.py @@ -27,6 +27,7 @@ TYPE_MISMATCH_ERROR, ) from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.lazy_payload import lazy from documentdb_tests.framework.parametrize import pytest_params from documentdb_tests.framework.property_checks import Eq, Exists from documentdb_tests.framework.target_collection import ViewCollection @@ -120,7 +121,9 @@ ), CommandTestCase( "allowdiskuse_true_memory_exceeded", - docs=[{"_id": i, "data": "x" * 100_000, "sort_key": 1050 - i} for i in range(1050)], + docs=lazy( + lambda: [{"_id": i, "data": "x" * 100_000, "sort_key": 1050 - i} for i in range(1050)] + ), command=lambda ctx: { "aggregate": ctx.collection, "pipeline": [{"$sort": {"sort_key": 1}}], @@ -170,7 +173,9 @@ ], CommandTestCase( "allowdiskuse_reject_memory_exceeded", - docs=[{"_id": i, "data": "x" * 100_000, "sort_key": 1050 - i} for i in range(1050)], + docs=lazy( + lambda: [{"_id": i, "data": "x" * 100_000, "sort_key": 1050 - i} for i in range(1050)] + ), command=lambda ctx: { "aggregate": ctx.collection, "pipeline": [{"$sort": {"sort_key": 1}}], diff --git a/documentdb_tests/compatibility/tests/core/aggregation/commands/aggregate/test_aggregate_cursor_acceptance.py b/documentdb_tests/compatibility/tests/core/aggregation/commands/aggregate/test_aggregate_cursor_acceptance.py index 3af51ca3c..8166053db 100644 --- a/documentdb_tests/compatibility/tests/core/aggregation/commands/aggregate/test_aggregate_cursor_acceptance.py +++ b/documentdb_tests/compatibility/tests/core/aggregation/commands/aggregate/test_aggregate_cursor_acceptance.py @@ -11,6 +11,7 @@ ) from documentdb_tests.framework.assertions import assertResult from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.lazy_payload import lazy from documentdb_tests.framework.parametrize import pytest_params from documentdb_tests.framework.property_checks import Eq, Ne from documentdb_tests.framework.test_constants import ( @@ -492,7 +493,7 @@ AGGREGATE_CURSOR_RESPONSE_LIMIT_TESTS: list[CommandTestCase] = [ CommandTestCase( "cursor_batchsize_16mb_limit", - docs=[{"_id": i, "data": "x" * 1_000_000} for i in range(20)], + docs=lazy(lambda: [{"_id": i, "data": "x" * 1_000_000} for i in range(20)]), command=lambda ctx: { "aggregate": ctx.collection, "pipeline": [], diff --git a/documentdb_tests/compatibility/tests/core/aggregation/commands/distinct/test_distinct_command_errors.py b/documentdb_tests/compatibility/tests/core/aggregation/commands/distinct/test_distinct_command_errors.py index bae3b78a0..c6f4a2413 100644 --- a/documentdb_tests/compatibility/tests/core/aggregation/commands/distinct/test_distinct_command_errors.py +++ b/documentdb_tests/compatibility/tests/core/aggregation/commands/distinct/test_distinct_command_errors.py @@ -24,6 +24,7 @@ UNRECOGNIZED_COMMAND_FIELD_ERROR, ) from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.lazy_payload import lazy from documentdb_tests.framework.parametrize import pytest_params from documentdb_tests.framework.test_constants import ( DECIMAL128_INFINITY, @@ -281,7 +282,7 @@ DISTINCT_BSON_SIZE_LIMIT_TESTS: list[CommandTestCase] = [ CommandTestCase( "bson_size_limit_exceeded", - docs=[{"_id": i, "x": f"v{i}" + "x" * 17_000} for i in range(1100)], + docs=lazy(lambda: [{"_id": i, "x": f"v{i}" + "x" * 17_000} for i in range(1100)]), command=lambda ctx: {"distinct": ctx.collection, "key": "x"}, error_code=DISTINCT_TOO_BIG_ERROR, msg="distinct should produce an error when results exceed the 16MB BSON size limit", diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/sum/test_accumulator_sum_boundary.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/sum/test_accumulator_sum_boundary.py index 5436159bf..e1c17b24d 100644 --- a/documentdb_tests/compatibility/tests/core/operator/accumulators/sum/test_accumulator_sum_boundary.py +++ b/documentdb_tests/compatibility/tests/core/operator/accumulators/sum/test_accumulator_sum_boundary.py @@ -10,6 +10,7 @@ ) from documentdb_tests.framework.assertions import assertSuccess from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.lazy_payload import lazy, materialize from documentdb_tests.framework.parametrize import pytest_params from documentdb_tests.framework.test_constants import ( DECIMAL128_NEGATIVE_ZERO, @@ -158,7 +159,7 @@ SUM_LARGE_GROUP_TESTS: list[AccumulatorTestCase] = [ AccumulatorTestCase( "large_group_10k_int1", - docs=[{"v": 1} for _ in range(10_000)], + docs=lazy(lambda: [{"v": 1} for _ in range(10_000)]), pipeline=[ {"$group": {"_id": None, "result": {"$sum": "$v"}}}, {"$project": {"_id": 0, "value": "$result", "type": {"$type": "$result"}}}, @@ -175,7 +176,7 @@ def test_accumulator_sum_boundary(collection, test_case: AccumulatorTestCase): """Test $sum integer boundary values and large group accumulation.""" if test_case.docs: - collection.insert_many(test_case.docs) + collection.insert_many(materialize(test_case.docs)) result = execute_command( collection, {"aggregate": collection.name, "pipeline": test_case.pipeline or [], "cursor": {}}, @@ -237,7 +238,7 @@ def test_accumulator_sum_boundary(collection, test_case: AccumulatorTestCase): def test_accumulator_sum_negative_zero(collection, test_case: AccumulatorTestCase): """Test $sum negative zero normalization.""" if test_case.docs: - collection.insert_many(test_case.docs) + collection.insert_many(materialize(test_case.docs)) result = execute_command( collection, {"aggregate": collection.name, "pipeline": test_case.pipeline or [], "cursor": {}}, diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/string/concat/test_concat_size_limit.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/concat/test_concat_size_limit.py index 7c061e611..56ee75885 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/string/concat/test_concat_size_limit.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/concat/test_concat_size_limit.py @@ -9,6 +9,7 @@ ) from documentdb_tests.framework.assertions import assertFailureCode from documentdb_tests.framework.error_codes import STRING_SIZE_LIMIT_ERROR +from documentdb_tests.framework.lazy_payload import lazy from documentdb_tests.framework.parametrize import pytest_params from documentdb_tests.framework.test_constants import STRING_SIZE_LIMIT_BYTES @@ -22,38 +23,48 @@ # Two large strings concatenated, just under the limit. ConcatTest( "size_two_args_one_under", - args=[ - "a" * ((STRING_SIZE_LIMIT_BYTES - 1) // 2), - "b" * ((STRING_SIZE_LIMIT_BYTES - 1) // 2), - ], - expected="a" * ((STRING_SIZE_LIMIT_BYTES - 1) // 2) - + "b" * ((STRING_SIZE_LIMIT_BYTES - 1) // 2), + args=lazy( + lambda: [ + "a" * ((STRING_SIZE_LIMIT_BYTES - 1) // 2), + "b" * ((STRING_SIZE_LIMIT_BYTES - 1) // 2), + ] + ), + expected=lazy( + lambda: "a" * ((STRING_SIZE_LIMIT_BYTES - 1) // 2) + + "b" * ((STRING_SIZE_LIMIT_BYTES - 1) // 2) + ), msg="$concat should handle two large strings concatenated together", ), ConcatTest( "size_one_under", - args=["a" * (STRING_SIZE_LIMIT_BYTES - 1)], - expected="a" * (STRING_SIZE_LIMIT_BYTES - 1), + args=lazy(lambda: ["a" * (STRING_SIZE_LIMIT_BYTES - 1)]), + expected=lazy(lambda: "a" * (STRING_SIZE_LIMIT_BYTES - 1)), msg="$concat should succeed when result is one byte under the size limit", ), # 2-byte chars: one byte under the limit. ConcatTest( "size_one_under_2byte", - args=["é" * ((STRING_SIZE_LIMIT_BYTES - 1) // 2) + "a"], - expected="é" * ((STRING_SIZE_LIMIT_BYTES - 1) // 2) + "a", + args=lazy(lambda: ["é" * ((STRING_SIZE_LIMIT_BYTES - 1) // 2) + "a"]), + expected=lazy(lambda: "é" * ((STRING_SIZE_LIMIT_BYTES - 1) // 2) + "a"), msg="$concat should succeed with 2-byte chars one byte under the limit", ), # 4-byte chars: one byte under the limit. ConcatTest( "size_one_under_4byte", - args=["😀" * ((STRING_SIZE_LIMIT_BYTES - 1) // 4) + "abc"], - expected="😀" * ((STRING_SIZE_LIMIT_BYTES - 1) // 4) + "abc", + args=lazy(lambda: ["😀" * ((STRING_SIZE_LIMIT_BYTES - 1) // 4) + "abc"]), + expected=lazy(lambda: "😀" * ((STRING_SIZE_LIMIT_BYTES - 1) // 4) + "abc"), msg="$concat should succeed with 4-byte chars one byte under the limit", ), # Null propagation wins when individual args are under the limit. ConcatTest( "size_null_precedence", - args=["a" * (STRING_SIZE_LIMIT_BYTES // 2), None, "b" * (STRING_SIZE_LIMIT_BYTES // 2)], + args=lazy( + lambda: [ + "a" * (STRING_SIZE_LIMIT_BYTES // 2), + None, + "b" * (STRING_SIZE_LIMIT_BYTES // 2), + ] + ), expected=None, msg="$concat should return null when null appears among large strings under the limit", ), @@ -63,45 +74,49 @@ STRING_SIZE_LIMIT_ERROR_TESTS: list[ConcatTest] = [ ConcatTest( "size_at_limit", - args=["a" * STRING_SIZE_LIMIT_BYTES], + args=lazy(lambda: ["a" * STRING_SIZE_LIMIT_BYTES]), error_code=STRING_SIZE_LIMIT_ERROR, msg="$concat should reject result at the size limit", ), # Two halves summing to exactly the limit. ConcatTest( "size_two_halves", - args=["a" * (STRING_SIZE_LIMIT_BYTES // 2), "b" * (STRING_SIZE_LIMIT_BYTES // 2)], + args=lazy( + lambda: ["a" * (STRING_SIZE_LIMIT_BYTES // 2), "b" * (STRING_SIZE_LIMIT_BYTES // 2)] + ), error_code=STRING_SIZE_LIMIT_ERROR, msg="$concat should reject two strings summing to the size limit", ), # 2-byte chars totaling exactly the limit. ConcatTest( "size_at_limit_2byte", - args=["é" * (STRING_SIZE_LIMIT_BYTES // 2)], + args=lazy(lambda: ["é" * (STRING_SIZE_LIMIT_BYTES // 2)]), error_code=STRING_SIZE_LIMIT_ERROR, msg="$concat should reject 2-byte chars totaling the size limit", ), # 4-byte chars totaling exactly the limit. ConcatTest( "size_at_limit_4byte", - args=["😀" * (STRING_SIZE_LIMIT_BYTES // 4)], + args=lazy(lambda: ["😀" * (STRING_SIZE_LIMIT_BYTES // 4)]), error_code=STRING_SIZE_LIMIT_ERROR, msg="$concat should reject 4-byte chars totaling the size limit", ), # Many small operands summing to exactly the limit. ConcatTest( "size_many_small", - args=["a" * (STRING_SIZE_LIMIT_BYTES // 1024)] * 1024, + args=lazy(lambda: ["a" * (STRING_SIZE_LIMIT_BYTES // 1024)] * 1024), error_code=STRING_SIZE_LIMIT_ERROR, msg="$concat should reject many small operands summing to the size limit", ), # Operand produced by a nested expression rather than a literal. ConcatTest( "size_nested", - args=[ - {"$concat": ["a" * (STRING_SIZE_LIMIT_BYTES // 2)]}, - "b" * (STRING_SIZE_LIMIT_BYTES // 2), - ], + args=lazy( + lambda: [ + {"$concat": ["a" * (STRING_SIZE_LIMIT_BYTES // 2)]}, + "b" * (STRING_SIZE_LIMIT_BYTES // 2), + ] + ), error_code=STRING_SIZE_LIMIT_ERROR, msg="$concat should reject nested expression result exceeding the size limit", ), @@ -123,8 +138,8 @@ def test_concat_size_limit_stored_field(collection): """Test $concat size limit is enforced when stored fields contribute to the result.""" result = execute_project_with_insert( collection, - {"s": "a" * (STRING_SIZE_LIMIT_BYTES // 2)}, - {"result": {"$concat": ["$s", "b" * (STRING_SIZE_LIMIT_BYTES // 2)]}}, + lazy(lambda: {"s": "a" * (STRING_SIZE_LIMIT_BYTES // 2)}), + lazy(lambda: {"result": {"$concat": ["$s", "b" * (STRING_SIZE_LIMIT_BYTES // 2)]}}), ) assertFailureCode( result, STRING_SIZE_LIMIT_ERROR, msg="$concat should enforce size limit with stored fields" diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/string/indexOfBytes/test_indexOfBytes_size_limit.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/indexOfBytes/test_indexOfBytes_size_limit.py index d13a3cdf4..9109a4459 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/string/indexOfBytes/test_indexOfBytes_size_limit.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/indexOfBytes/test_indexOfBytes_size_limit.py @@ -7,6 +7,7 @@ execute_expression, ) from documentdb_tests.framework.error_codes import STRING_SIZE_LIMIT_ERROR +from documentdb_tests.framework.lazy_payload import lazy from documentdb_tests.framework.parametrize import pytest_params from documentdb_tests.framework.test_constants import STRING_SIZE_LIMIT_BYTES @@ -19,34 +20,34 @@ INDEXOFBYTES_SIZE_LIMIT_SUCCESS_TESTS: list[IndexOfBytesTest] = [ IndexOfBytesTest( "size_string_one_under", - args=["a" * (STRING_SIZE_LIMIT_BYTES - 1), "a"], + args=lazy(lambda: ["a" * (STRING_SIZE_LIMIT_BYTES - 1), "a"]), expected=0, msg="$indexOfBytes should accept string one byte under the size limit", ), IndexOfBytesTest( "size_substring_one_under", - args=["hello", "a" * (STRING_SIZE_LIMIT_BYTES - 1)], + args=lazy(lambda: ["hello", "a" * (STRING_SIZE_LIMIT_BYTES - 1)]), expected=-1, msg="$indexOfBytes should accept substring one byte under the size limit", ), # 2-byte chars: one byte under the limit. Limit is byte-based, not codepoint-based. IndexOfBytesTest( "size_string_one_under_2byte", - args=["é" * ((STRING_SIZE_LIMIT_BYTES - 1) // 2) + "a", "a"], + args=lazy(lambda: ["é" * ((STRING_SIZE_LIMIT_BYTES - 1) // 2) + "a", "a"]), expected=STRING_SIZE_LIMIT_BYTES - 2, msg="$indexOfBytes should accept 2-byte char string one byte under limit", ), # Found at end of a large string. IndexOfBytesTest( "size_found_at_end", - args=["a" * (STRING_SIZE_LIMIT_BYTES - 2) + "b", "b"], + args=lazy(lambda: ["a" * (STRING_SIZE_LIMIT_BYTES - 2) + "b", "b"]), expected=STRING_SIZE_LIMIT_BYTES - 2, msg="$indexOfBytes should find match at end of a large string", ), # Not found in a large string. IndexOfBytesTest( "size_not_found", - args=["a" * (STRING_SIZE_LIMIT_BYTES - 1), "b"], + args=lazy(lambda: ["a" * (STRING_SIZE_LIMIT_BYTES - 1), "b"]), expected=-1, msg="$indexOfBytes should return -1 for no match in a large string", ), @@ -57,20 +58,20 @@ INDEXOFBYTES_SIZE_LIMIT_ERROR_TESTS: list[IndexOfBytesTest] = [ IndexOfBytesTest( "size_string_at_limit", - args=["a" * STRING_SIZE_LIMIT_BYTES, "a"], + args=lazy(lambda: ["a" * STRING_SIZE_LIMIT_BYTES, "a"]), error_code=STRING_SIZE_LIMIT_ERROR, msg="$indexOfBytes should reject string at the size limit", ), IndexOfBytesTest( "size_substring_at_limit", - args=["hello", "a" * STRING_SIZE_LIMIT_BYTES], + args=lazy(lambda: ["hello", "a" * STRING_SIZE_LIMIT_BYTES]), error_code=STRING_SIZE_LIMIT_ERROR, msg="$indexOfBytes should reject substring at the size limit", ), # 2-byte chars: exactly at the limit. Limit is byte-based, not codepoint-based. IndexOfBytesTest( "size_string_at_limit_2byte", - args=["é" * (STRING_SIZE_LIMIT_BYTES // 2), "a"], + args=lazy(lambda: ["é" * (STRING_SIZE_LIMIT_BYTES // 2), "a"]), error_code=STRING_SIZE_LIMIT_ERROR, msg="$indexOfBytes should reject 2-byte char string at the byte size limit", ), diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/string/indexOfBytes/test_indexOfBytes_usage.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/indexOfBytes/test_indexOfBytes_usage.py index 63eefece7..e169e9745 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/string/indexOfBytes/test_indexOfBytes_usage.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/indexOfBytes/test_indexOfBytes_usage.py @@ -8,6 +8,7 @@ execute_project_with_insert, ) from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.lazy_payload import lazy from documentdb_tests.framework.parametrize import pytest_params from documentdb_tests.framework.test_constants import STRING_SIZE_LIMIT_BYTES @@ -123,7 +124,7 @@ def test_indexofbytes_nested_field_paths(collection): ), IndexOfBytesTest( "return_type_large_index", - args=["a" * (STRING_SIZE_LIMIT_BYTES - 2) + "b", "b"], + args=lazy(lambda: ["a" * (STRING_SIZE_LIMIT_BYTES - 2) + "b", "b"]), msg="$indexOfBytes should return int type for large index value", ), ] diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/string/indexOfCP/test_indexOfCP_size_limit.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/indexOfCP/test_indexOfCP_size_limit.py index b9ded658d..f3bd9d46b 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/string/indexOfCP/test_indexOfCP_size_limit.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/indexOfCP/test_indexOfCP_size_limit.py @@ -7,6 +7,7 @@ execute_expression, ) from documentdb_tests.framework.error_codes import STRING_SIZE_LIMIT_ERROR +from documentdb_tests.framework.lazy_payload import lazy from documentdb_tests.framework.parametrize import pytest_params from documentdb_tests.framework.test_constants import STRING_SIZE_LIMIT_BYTES @@ -19,34 +20,34 @@ INDEXOFCP_SIZE_LIMIT_SUCCESS_TESTS: list[IndexOfCPTest] = [ IndexOfCPTest( "size_string_one_under", - args=["a" * (STRING_SIZE_LIMIT_BYTES - 1), "a"], + args=lazy(lambda: ["a" * (STRING_SIZE_LIMIT_BYTES - 1), "a"]), expected=0, msg="$indexOfCP should accept string one byte under the size limit", ), IndexOfCPTest( "size_substr_one_under", - args=["hello", "a" * (STRING_SIZE_LIMIT_BYTES - 1)], + args=lazy(lambda: ["hello", "a" * (STRING_SIZE_LIMIT_BYTES - 1)]), expected=-1, msg="$indexOfCP should accept substring one byte under the size limit", ), # 2-byte chars: one byte under the limit. Limit is byte-based, not code-point-based. IndexOfCPTest( "size_string_one_under_2byte", - args=["é" * ((STRING_SIZE_LIMIT_BYTES - 1) // 2) + "a", "a"], + args=lazy(lambda: ["é" * ((STRING_SIZE_LIMIT_BYTES - 1) // 2) + "a", "a"]), expected=(STRING_SIZE_LIMIT_BYTES - 1) // 2, msg="$indexOfCP should accept 2-byte char string one byte under limit", ), # Found at end of a large string. IndexOfCPTest( "size_found_at_end", - args=["a" * (STRING_SIZE_LIMIT_BYTES - 2) + "b", "b"], + args=lazy(lambda: ["a" * (STRING_SIZE_LIMIT_BYTES - 2) + "b", "b"]), expected=STRING_SIZE_LIMIT_BYTES - 2, msg="$indexOfCP should find match at end of a large string", ), # Not found in a large string. IndexOfCPTest( "size_not_found", - args=["a" * (STRING_SIZE_LIMIT_BYTES - 1), "b"], + args=lazy(lambda: ["a" * (STRING_SIZE_LIMIT_BYTES - 1), "b"]), expected=-1, msg="$indexOfCP should return -1 for no match in a large string", ), @@ -58,20 +59,20 @@ INDEXOFCP_SIZE_LIMIT_ERROR_TESTS: list[IndexOfCPTest] = [ IndexOfCPTest( "size_string_at_limit", - args=["a" * STRING_SIZE_LIMIT_BYTES, "b"], + args=lazy(lambda: ["a" * STRING_SIZE_LIMIT_BYTES, "b"]), error_code=STRING_SIZE_LIMIT_ERROR, msg="$indexOfCP should reject string at the size limit", ), IndexOfCPTest( "size_substr_at_limit", - args=["hello", "a" * STRING_SIZE_LIMIT_BYTES], + args=lazy(lambda: ["hello", "a" * STRING_SIZE_LIMIT_BYTES]), error_code=STRING_SIZE_LIMIT_ERROR, msg="$indexOfCP should reject substring at the size limit", ), # 2-byte chars: exactly at the limit. Limit is byte-based, not code-point-based. IndexOfCPTest( "size_string_at_limit_2byte", - args=["é" * (STRING_SIZE_LIMIT_BYTES // 2), "b"], + args=lazy(lambda: ["é" * (STRING_SIZE_LIMIT_BYTES // 2), "b"]), error_code=STRING_SIZE_LIMIT_ERROR, msg="$indexOfCP should reject 2-byte char string at the byte size limit", ), diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/string/indexOfCP/test_indexOfCP_usage.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/indexOfCP/test_indexOfCP_usage.py index e7ee72d66..43c98d6f9 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/string/indexOfCP/test_indexOfCP_usage.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/indexOfCP/test_indexOfCP_usage.py @@ -8,6 +8,7 @@ execute_project_with_insert, ) from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.lazy_payload import lazy from documentdb_tests.framework.parametrize import pytest_params from documentdb_tests.framework.test_constants import STRING_SIZE_LIMIT_BYTES @@ -124,7 +125,7 @@ def test_indexofcp_nested_field_paths(collection): ), IndexOfCPTest( "return_type_large_index", - args=["a" * (STRING_SIZE_LIMIT_BYTES - 2) + "b", "b"], + args=lazy(lambda: ["a" * (STRING_SIZE_LIMIT_BYTES - 2) + "b", "b"]), msg="$indexOfCP should return int type for large index value", ), ] diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/string/ltrim/test_ltrim_size_limit.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/ltrim/test_ltrim_size_limit.py index cb62bd5aa..12c5bda80 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/string/ltrim/test_ltrim_size_limit.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/ltrim/test_ltrim_size_limit.py @@ -7,6 +7,7 @@ execute_expression, ) from documentdb_tests.framework.error_codes import STRING_SIZE_LIMIT_ERROR +from documentdb_tests.framework.lazy_payload import lazy from documentdb_tests.framework.parametrize import pytest_params from documentdb_tests.framework.test_constants import STRING_SIZE_LIMIT_BYTES @@ -19,21 +20,21 @@ LTRIM_SIZE_LIMIT_SUCCESS_TESTS: list[LtrimTest] = [ LtrimTest( "size_one_under", - input="a" * (STRING_SIZE_LIMIT_BYTES - 1), - expected="a" * (STRING_SIZE_LIMIT_BYTES - 1), + input=lazy(lambda: "a" * (STRING_SIZE_LIMIT_BYTES - 1)), + expected=lazy(lambda: "a" * (STRING_SIZE_LIMIT_BYTES - 1)), msg="$ltrim should accept input one byte under the size limit", ), # 2-byte chars: one byte under the limit. LtrimTest( "size_one_under_2byte", - input="\u00e9" * ((STRING_SIZE_LIMIT_BYTES - 1) // 2) + "a", - expected="\u00e9" * ((STRING_SIZE_LIMIT_BYTES - 1) // 2) + "a", + input=lazy(lambda: "\u00e9" * ((STRING_SIZE_LIMIT_BYTES - 1) // 2) + "a"), + expected=lazy(lambda: "\u00e9" * ((STRING_SIZE_LIMIT_BYTES - 1) // 2) + "a"), msg="$ltrim should accept 2-byte character input one byte under the size limit", ), # Large input with many leading trim characters, just under the limit. LtrimTest( "size_trim_leading", - input="a" * (STRING_SIZE_LIMIT_BYTES - 6) + "hello", + input=lazy(lambda: "a" * (STRING_SIZE_LIMIT_BYTES - 6) + "hello"), chars="a", expected="hello", msg="$ltrim should trim many leading characters near the size limit", @@ -45,13 +46,13 @@ LTRIM_SIZE_LIMIT_ERROR_TESTS: list[LtrimTest] = [ LtrimTest( "size_at_limit", - input="a" * STRING_SIZE_LIMIT_BYTES, + input=lazy(lambda: "a" * STRING_SIZE_LIMIT_BYTES), error_code=STRING_SIZE_LIMIT_ERROR, msg="$ltrim should reject input at the BSON string byte limit", ), LtrimTest( "size_at_limit_2byte", - input="\u00e9" * (STRING_SIZE_LIMIT_BYTES // 2), + input=lazy(lambda: "\u00e9" * (STRING_SIZE_LIMIT_BYTES // 2)), error_code=STRING_SIZE_LIMIT_ERROR, msg="$ltrim should reject 2-byte character input at the BSON string byte limit", ), diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/string/regexFind/test_regexFind_size_limit.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/regexFind/test_regexFind_size_limit.py index 0c9eb6a0e..b440102dd 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/string/regexFind/test_regexFind_size_limit.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/regexFind/test_regexFind_size_limit.py @@ -7,6 +7,7 @@ execute_expression, ) from documentdb_tests.framework.error_codes import REGEX_BAD_PATTERN_ERROR, STRING_SIZE_LIMIT_ERROR +from documentdb_tests.framework.lazy_payload import lazy from documentdb_tests.framework.parametrize import pytest_params from documentdb_tests.framework.test_constants import ( REGEX_PATTERN_LIMIT_BYTES, @@ -22,16 +23,16 @@ REGEXFIND_SIZE_LIMIT_SUCCESS_TESTS: list[RegexFindTest] = [ RegexFindTest( "size_one_under", - input="a" * (STRING_SIZE_LIMIT_BYTES - 4) + "XYZ", + input=lazy(lambda: "a" * (STRING_SIZE_LIMIT_BYTES - 4) + "XYZ"), regex="XYZ", - expected={"match": "XYZ", "idx": STRING_SIZE_LIMIT_BYTES - 4, "captures": []}, + expected=lazy(lambda: {"match": "XYZ", "idx": STRING_SIZE_LIMIT_BYTES - 4, "captures": []}), msg="$regexFind should accept input one byte under the size limit", ), RegexFindTest( "size_regex_at_pattern_limit", - input="a" * REGEX_PATTERN_LIMIT_BYTES, - regex="a" * REGEX_PATTERN_LIMIT_BYTES, - expected={"match": "a" * REGEX_PATTERN_LIMIT_BYTES, "idx": 0, "captures": []}, + input=lazy(lambda: "a" * REGEX_PATTERN_LIMIT_BYTES), + regex=lazy(lambda: "a" * REGEX_PATTERN_LIMIT_BYTES), + expected=lazy(lambda: {"match": "a" * REGEX_PATTERN_LIMIT_BYTES, "idx": 0, "captures": []}), msg="$regexFind should accept regex at the pattern length limit", ), ] @@ -41,7 +42,7 @@ REGEXFIND_SIZE_LIMIT_ERROR_TESTS: list[RegexFindTest] = [ RegexFindTest( "size_at_limit", - input="a" * STRING_SIZE_LIMIT_BYTES, + input=lazy(lambda: "a" * STRING_SIZE_LIMIT_BYTES), regex="a", error_code=STRING_SIZE_LIMIT_ERROR, msg="$regexFind should reject input at the size limit", @@ -49,7 +50,7 @@ RegexFindTest( "size_regex_over_pattern_limit", input="a", - regex="a" * (REGEX_PATTERN_LIMIT_BYTES + 1), + regex=lazy(lambda: "a" * (REGEX_PATTERN_LIMIT_BYTES + 1)), error_code=REGEX_BAD_PATTERN_ERROR, msg="$regexFind should reject regex over the pattern length limit", ), diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/string/regexFindAll/test_regexFindAll_matching.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/regexFindAll/test_regexFindAll_matching.py index 02af66a43..2e4149ff1 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/string/regexFindAll/test_regexFindAll_matching.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/regexFindAll/test_regexFindAll_matching.py @@ -6,6 +6,7 @@ assert_expression_result, execute_expression, ) +from documentdb_tests.framework.lazy_payload import lazy from documentdb_tests.framework.parametrize import pytest_params from .utils.regexFindAll_common import ( @@ -135,9 +136,11 @@ # performant; scaling to STRING_SIZE_LIMIT_BYTES would produce ~8M matches and hang. RegexFindAllTest( "edge_large_input_many_matches", - input="ab" * 5_000, + input=lazy(lambda: "ab" * 5_000), regex="ab", - expected=[{"match": "ab", "idx": i * 2, "captures": []} for i in range(5_000)], + expected=lazy( + lambda: [{"match": "ab", "idx": i * 2, "captures": []} for i in range(5_000)] + ), msg="$regexFindAll should return all 5000 matches from a large repeated input", ), # Newline in input. @@ -196,7 +199,7 @@ expected=[{"match": "aaa", "idx": 0, "captures": []}], msg="$regexFindAll greedy quantifier should consume maximum input in one match", ), - # Lazy quantifier consumes minimum input, increasing match count. + # lazy quantifier consumes minimum input, increasing match count. RegexFindAllTest( "multi_nongreedy_more_matches", input="aaa", diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/string/regexFindAll/test_regexFindAll_size_limit.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/regexFindAll/test_regexFindAll_size_limit.py index 12299a447..9f4a4fc7e 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/string/regexFindAll/test_regexFindAll_size_limit.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/regexFindAll/test_regexFindAll_size_limit.py @@ -7,6 +7,7 @@ execute_expression, ) from documentdb_tests.framework.error_codes import REGEX_BAD_PATTERN_ERROR, STRING_SIZE_LIMIT_ERROR +from documentdb_tests.framework.lazy_payload import lazy from documentdb_tests.framework.parametrize import pytest_params from documentdb_tests.framework.test_constants import ( REGEX_PATTERN_LIMIT_BYTES, @@ -22,21 +23,25 @@ REGEXFINDALL_SIZE_LIMIT_SUCCESS_TESTS: list[RegexFindAllTest] = [ RegexFindAllTest( "size_one_under", - input="a" * (STRING_SIZE_LIMIT_BYTES - 4) + "XYZ", + input=lazy(lambda: "a" * (STRING_SIZE_LIMIT_BYTES - 4) + "XYZ"), regex="XYZ", - expected=[{"match": "XYZ", "idx": STRING_SIZE_LIMIT_BYTES - 4, "captures": []}], + expected=lazy( + lambda: [{"match": "XYZ", "idx": STRING_SIZE_LIMIT_BYTES - 4, "captures": []}] + ), msg="$regexFindAll should accept input one byte under the size limit", ), RegexFindAllTest( "size_regex_at_pattern_limit", - input="a" * REGEX_PATTERN_LIMIT_BYTES, - regex="a" * REGEX_PATTERN_LIMIT_BYTES, - expected=[{"match": "a" * REGEX_PATTERN_LIMIT_BYTES, "idx": 0, "captures": []}], + input=lazy(lambda: "a" * REGEX_PATTERN_LIMIT_BYTES), + regex=lazy(lambda: "a" * REGEX_PATTERN_LIMIT_BYTES), + expected=lazy( + lambda: [{"match": "a" * REGEX_PATTERN_LIMIT_BYTES, "idx": 0, "captures": []}] + ), msg="$regexFindAll should accept regex at the pattern length limit", ), RegexFindAllTest( "size_two_matches", - input="XY" + "a" * (STRING_SIZE_LIMIT_BYTES - 5) + "XY", + input=lazy(lambda: "XY" + "a" * (STRING_SIZE_LIMIT_BYTES - 5) + "XY"), regex="XY", expected=[ {"match": "XY", "idx": 0, "captures": []}, @@ -52,7 +57,7 @@ REGEXFINDALL_SIZE_LIMIT_ERROR_TESTS: list[RegexFindAllTest] = [ RegexFindAllTest( "size_at_limit", - input="a" * STRING_SIZE_LIMIT_BYTES, + input=lazy(lambda: "a" * STRING_SIZE_LIMIT_BYTES), regex="a", error_code=STRING_SIZE_LIMIT_ERROR, msg="$regexFindAll should reject input at the size limit", @@ -60,7 +65,7 @@ RegexFindAllTest( "size_regex_over_pattern_limit", input="a", - regex="a" * (REGEX_PATTERN_LIMIT_BYTES + 1), + regex=lazy(lambda: "a" * (REGEX_PATTERN_LIMIT_BYTES + 1)), error_code=REGEX_BAD_PATTERN_ERROR, msg="$regexFindAll should reject regex over the pattern length limit", ), diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/string/regexMatch/test_regexMatch_size_limit.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/regexMatch/test_regexMatch_size_limit.py index 03cdc329f..02c29a34e 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/string/regexMatch/test_regexMatch_size_limit.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/regexMatch/test_regexMatch_size_limit.py @@ -7,6 +7,7 @@ execute_expression, ) from documentdb_tests.framework.error_codes import REGEX_BAD_PATTERN_ERROR, STRING_SIZE_LIMIT_ERROR +from documentdb_tests.framework.lazy_payload import lazy from documentdb_tests.framework.parametrize import pytest_params from documentdb_tests.framework.test_constants import ( REGEX_PATTERN_LIMIT_BYTES, @@ -22,15 +23,15 @@ REGEXMATCH_SIZE_LIMIT_SUCCESS_TESTS: list[RegexMatchTest] = [ RegexMatchTest( "size_one_under", - input="a" * (STRING_SIZE_LIMIT_BYTES - 4) + "XYZ", + input=lazy(lambda: "a" * (STRING_SIZE_LIMIT_BYTES - 4) + "XYZ"), regex="XYZ", expected=True, msg="$regexMatch should accept input one byte under the size limit", ), RegexMatchTest( "size_regex_at_pattern_limit", - input="a" * REGEX_PATTERN_LIMIT_BYTES, - regex="a" * REGEX_PATTERN_LIMIT_BYTES, + input=lazy(lambda: "a" * REGEX_PATTERN_LIMIT_BYTES), + regex=lazy(lambda: "a" * REGEX_PATTERN_LIMIT_BYTES), expected=True, msg="$regexMatch should accept regex at the pattern length limit", ), @@ -41,7 +42,7 @@ REGEXMATCH_SIZE_LIMIT_ERROR_TESTS: list[RegexMatchTest] = [ RegexMatchTest( "size_at_limit", - input="a" * STRING_SIZE_LIMIT_BYTES, + input=lazy(lambda: "a" * STRING_SIZE_LIMIT_BYTES), regex="a", error_code=STRING_SIZE_LIMIT_ERROR, msg="$regexMatch should reject input at the size limit", @@ -49,7 +50,7 @@ RegexMatchTest( "size_regex_over_pattern_limit", input="a", - regex="a" * (REGEX_PATTERN_LIMIT_BYTES + 1), + regex=lazy(lambda: "a" * (REGEX_PATTERN_LIMIT_BYTES + 1)), error_code=REGEX_BAD_PATTERN_ERROR, msg="$regexMatch should reject regex over the pattern length limit", ), diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_size_limit.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_size_limit.py index 4d6356bd8..8ff8f2dd5 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_size_limit.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_size_limit.py @@ -7,6 +7,7 @@ execute_expression, ) from documentdb_tests.framework.error_codes import STRING_SIZE_LIMIT_ERROR +from documentdb_tests.framework.lazy_payload import lazy from documentdb_tests.framework.parametrize import pytest_params from documentdb_tests.framework.test_constants import STRING_SIZE_LIMIT_BYTES @@ -21,10 +22,10 @@ REPLACEALL_SIZE_LIMIT_SUCCESS_TESTS: list[ReplaceAllTest] = [ ReplaceAllTest( "size_success_input_max", - input="a" * (STRING_SIZE_LIMIT_BYTES - 1), + input=lazy(lambda: "a" * (STRING_SIZE_LIMIT_BYTES - 1)), find="xyz", replacement="abc", - expected="a" * (STRING_SIZE_LIMIT_BYTES - 1), + expected=lazy(lambda: "a" * (STRING_SIZE_LIMIT_BYTES - 1)), msg="$replaceAll size limit: success input max", ), # Replacement amplification producing result one under the limit. @@ -32,26 +33,26 @@ "size_success_amplification_max", input="a", find="a", - replacement="a" * (STRING_SIZE_LIMIT_BYTES - 1), - expected="a" * (STRING_SIZE_LIMIT_BYTES - 1), + replacement=lazy(lambda: "a" * (STRING_SIZE_LIMIT_BYTES - 1)), + expected=lazy(lambda: "a" * (STRING_SIZE_LIMIT_BYTES - 1)), msg="$replaceAll size limit: success amplification max", ), # 4-byte emoji at one character below the byte limit. ReplaceAllTest( "size_success_4byte_max", - input="\U0001f600" * ((STRING_SIZE_LIMIT_BYTES - 1) // 4), + input=lazy(lambda: "\U0001f600" * ((STRING_SIZE_LIMIT_BYTES - 1) // 4)), find="xyz", replacement="abc", - expected="\U0001f600" * ((STRING_SIZE_LIMIT_BYTES - 1) // 4), + expected=lazy(lambda: "\U0001f600" * ((STRING_SIZE_LIMIT_BYTES - 1) // 4)), msg="$replaceAll size limit: success 4byte max", ), # Empty find amplification just under the limit. ReplaceAllTest( "size_success_empty_find_amplification", - input="a" * ((STRING_SIZE_LIMIT_BYTES - 2) // 2), + input=lazy(lambda: "a" * ((STRING_SIZE_LIMIT_BYTES - 2) // 2)), find="", replacement="X", - expected="X" + "aX" * ((STRING_SIZE_LIMIT_BYTES - 2) // 2), + expected=lazy(lambda: "X" + "aX" * ((STRING_SIZE_LIMIT_BYTES - 2) // 2)), msg="$replaceAll size limit: success empty find amplification", ), ] @@ -63,7 +64,7 @@ # Exactly at the limit. ReplaceAllTest( "size_error_input_at_limit", - input="a" * STRING_SIZE_LIMIT_BYTES, + input=lazy(lambda: "a" * STRING_SIZE_LIMIT_BYTES), find="a", replacement="b", error_code=STRING_SIZE_LIMIT_ERROR, @@ -72,7 +73,7 @@ # 2-byte chars at the limit. ReplaceAllTest( "size_error_byte_based_2byte", - input="\u00e9" * (STRING_SIZE_LIMIT_BYTES // 2), + input=lazy(lambda: "\u00e9" * (STRING_SIZE_LIMIT_BYTES // 2)), find="\u00e9", replacement="e", error_code=STRING_SIZE_LIMIT_ERROR, @@ -81,7 +82,7 @@ # 4-byte chars at the limit. ReplaceAllTest( "size_error_byte_based_4byte", - input="\U0001f600" * (STRING_SIZE_LIMIT_BYTES // 4), + input=lazy(lambda: "\U0001f600" * (STRING_SIZE_LIMIT_BYTES // 4)), find="\U0001f600", replacement="x", error_code=STRING_SIZE_LIMIT_ERROR, @@ -90,7 +91,7 @@ # Input literal rejected even when replacement would shrink the result. ReplaceAllTest( "size_error_input_shrinking_rejected", - input="a" * STRING_SIZE_LIMIT_BYTES, + input=lazy(lambda: "a" * STRING_SIZE_LIMIT_BYTES), find="a" * 100, replacement="", error_code=STRING_SIZE_LIMIT_ERROR, @@ -99,7 +100,7 @@ # Result amplification: input under limit, replacement grows result to limit. ReplaceAllTest( "size_error_result_amplification", - input="a" * (STRING_SIZE_LIMIT_BYTES - 1), + input=lazy(lambda: "a" * (STRING_SIZE_LIMIT_BYTES - 1)), find="a", replacement="aa", error_code=STRING_SIZE_LIMIT_ERROR, @@ -109,7 +110,7 @@ ReplaceAllTest( "size_error_find_at_limit", input="hello", - find="a" * STRING_SIZE_LIMIT_BYTES, + find=lazy(lambda: "a" * STRING_SIZE_LIMIT_BYTES), replacement="b", error_code=STRING_SIZE_LIMIT_ERROR, msg="$replaceAll size limit: error find at limit", @@ -119,7 +120,7 @@ "size_error_replacement_at_limit", input="hello", find="a", - replacement="a" * STRING_SIZE_LIMIT_BYTES, + replacement=lazy(lambda: "a" * STRING_SIZE_LIMIT_BYTES), error_code=STRING_SIZE_LIMIT_ERROR, msg="$replaceAll size limit: error replacement at limit", ), diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_size_limit.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_size_limit.py index 286f93391..3b9973173 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_size_limit.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_size_limit.py @@ -7,6 +7,7 @@ execute_expression, ) from documentdb_tests.framework.error_codes import STRING_SIZE_LIMIT_ERROR +from documentdb_tests.framework.lazy_payload import lazy from documentdb_tests.framework.parametrize import pytest_params from documentdb_tests.framework.test_constants import STRING_SIZE_LIMIT_BYTES @@ -21,25 +22,25 @@ REPLACEONE_SIZE_LIMIT_SUCCESS_TESTS: list[ReplaceOneTest] = [ ReplaceOneTest( "size_success_input_max", - input="a" * (STRING_SIZE_LIMIT_BYTES - 1), + input=lazy(lambda: "a" * (STRING_SIZE_LIMIT_BYTES - 1)), find="xyz", replacement="abc", - expected="a" * (STRING_SIZE_LIMIT_BYTES - 1), + expected=lazy(lambda: "a" * (STRING_SIZE_LIMIT_BYTES - 1)), msg="$replaceOne size limit: success input max", ), # Large input with a shrinking replacement stays under the limit. ReplaceOneTest( "size_success_shrinking_replacement", - input="a" * (STRING_SIZE_LIMIT_BYTES - 1), + input=lazy(lambda: "a" * (STRING_SIZE_LIMIT_BYTES - 1)), find="a", replacement="", - expected="a" * (STRING_SIZE_LIMIT_BYTES - 2), + expected=lazy(lambda: "a" * (STRING_SIZE_LIMIT_BYTES - 2)), msg="$replaceOne size limit: success shrinking replacement", ), ReplaceOneTest( "size_success_find_max", input="hello", - find="a" * (STRING_SIZE_LIMIT_BYTES - 1), + find=lazy(lambda: "a" * (STRING_SIZE_LIMIT_BYTES - 1)), replacement="X", expected="hello", msg="$replaceOne size limit: success find max", @@ -47,40 +48,44 @@ # Replace at start of a near-limit string. ReplaceOneTest( "size_find_at_start", - input="X" + "a" * (STRING_SIZE_LIMIT_BYTES - 2), + input=lazy(lambda: "X" + "a" * (STRING_SIZE_LIMIT_BYTES - 2)), find="X", replacement="Y", - expected="Y" + "a" * (STRING_SIZE_LIMIT_BYTES - 2), + expected=lazy(lambda: "Y" + "a" * (STRING_SIZE_LIMIT_BYTES - 2)), msg="$replaceOne size limit: find at start", ), # Replace at end of a near-limit string. ReplaceOneTest( "size_find_at_end", - input="a" * (STRING_SIZE_LIMIT_BYTES - 2) + "X", + input=lazy(lambda: "a" * (STRING_SIZE_LIMIT_BYTES - 2) + "X"), find="X", replacement="Y", - expected="a" * (STRING_SIZE_LIMIT_BYTES - 2) + "Y", + expected=lazy(lambda: "a" * (STRING_SIZE_LIMIT_BYTES - 2) + "Y"), msg="$replaceOne size limit: find at end", ), # Replace in middle of a near-limit string. ReplaceOneTest( "size_find_in_middle", - input="a" * ((STRING_SIZE_LIMIT_BYTES - 2) // 2) - + "X" - + "a" * ((STRING_SIZE_LIMIT_BYTES - 2) // 2), + input=lazy( + lambda: "a" * ((STRING_SIZE_LIMIT_BYTES - 2) // 2) + + "X" + + "a" * ((STRING_SIZE_LIMIT_BYTES - 2) // 2) + ), find="X", replacement="Y", - expected="a" * ((STRING_SIZE_LIMIT_BYTES - 2) // 2) - + "Y" - + "a" * ((STRING_SIZE_LIMIT_BYTES - 2) // 2), + expected=lazy( + lambda: "a" * ((STRING_SIZE_LIMIT_BYTES - 2) // 2) + + "Y" + + "a" * ((STRING_SIZE_LIMIT_BYTES - 2) // 2) + ), msg="$replaceOne size limit: find in middle", ), # Large find string replaces entire input. Both at half-limit because two # near-limit strings in one command would exceed the BSON document size. ReplaceOneTest( "size_large_find", - input="a" * ((STRING_SIZE_LIMIT_BYTES - 1) // 2), - find="a" * ((STRING_SIZE_LIMIT_BYTES - 1) // 2), + input=lazy(lambda: "a" * ((STRING_SIZE_LIMIT_BYTES - 1) // 2)), + find=lazy(lambda: "a" * ((STRING_SIZE_LIMIT_BYTES - 1) // 2)), replacement="X", expected="X", msg="$replaceOne size limit: large find", @@ -90,8 +95,8 @@ "size_large_replacement", input="X", find="X", - replacement="a" * (STRING_SIZE_LIMIT_BYTES - 1), - expected="a" * (STRING_SIZE_LIMIT_BYTES - 1), + replacement=lazy(lambda: "a" * (STRING_SIZE_LIMIT_BYTES - 1)), + expected=lazy(lambda: "a" * (STRING_SIZE_LIMIT_BYTES - 1)), msg="$replaceOne size limit: large replacement", ), ] @@ -104,7 +109,7 @@ # Exactly at the limit. ReplaceOneTest( "size_error_input_at_limit", - input="a" * STRING_SIZE_LIMIT_BYTES, + input=lazy(lambda: "a" * STRING_SIZE_LIMIT_BYTES), find="a", replacement="b", error_code=STRING_SIZE_LIMIT_ERROR, @@ -113,7 +118,7 @@ # 2-byte chars at the limit. ReplaceOneTest( "size_error_byte_based_2byte", - input="\u00e9" * (STRING_SIZE_LIMIT_BYTES // 2), + input=lazy(lambda: "\u00e9" * (STRING_SIZE_LIMIT_BYTES // 2)), find="\u00e9", replacement="e", error_code=STRING_SIZE_LIMIT_ERROR, @@ -122,7 +127,7 @@ # 4-byte chars at the limit. ReplaceOneTest( "size_error_byte_based_4byte", - input="\U0001f600" * (STRING_SIZE_LIMIT_BYTES // 4), + input=lazy(lambda: "\U0001f600" * (STRING_SIZE_LIMIT_BYTES // 4)), find="\U0001f600", replacement="x", error_code=STRING_SIZE_LIMIT_ERROR, @@ -131,7 +136,7 @@ # Input literal rejected even when replacement would shrink the result. ReplaceOneTest( "size_error_input_shrinking_rejected", - input="a" * STRING_SIZE_LIMIT_BYTES, + input=lazy(lambda: "a" * STRING_SIZE_LIMIT_BYTES), find="a" * 100, replacement="", error_code=STRING_SIZE_LIMIT_ERROR, @@ -140,7 +145,7 @@ # Result amplification: input under limit, replacement grows result to limit. ReplaceOneTest( "size_error_result_amplification", - input="a" * (STRING_SIZE_LIMIT_BYTES - 1), + input=lazy(lambda: "a" * (STRING_SIZE_LIMIT_BYTES - 1)), find="a", replacement="aa", error_code=STRING_SIZE_LIMIT_ERROR, @@ -150,7 +155,7 @@ ReplaceOneTest( "size_error_find_at_limit", input="hello", - find="a" * STRING_SIZE_LIMIT_BYTES, + find=lazy(lambda: "a" * STRING_SIZE_LIMIT_BYTES), replacement="b", error_code=STRING_SIZE_LIMIT_ERROR, msg="$replaceOne size limit: error find at limit", diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/string/rtrim/test_rtrim_size_limit.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/rtrim/test_rtrim_size_limit.py index 839b8bb9d..c334bd207 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/string/rtrim/test_rtrim_size_limit.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/rtrim/test_rtrim_size_limit.py @@ -7,6 +7,7 @@ execute_expression, ) from documentdb_tests.framework.error_codes import STRING_SIZE_LIMIT_ERROR +from documentdb_tests.framework.lazy_payload import lazy from documentdb_tests.framework.parametrize import pytest_params from documentdb_tests.framework.test_constants import STRING_SIZE_LIMIT_BYTES @@ -19,21 +20,21 @@ RTRIM_SIZE_LIMIT_SUCCESS_TESTS: list[RtrimTest] = [ RtrimTest( "size_one_under", - input="a" * (STRING_SIZE_LIMIT_BYTES - 1), - expected="a" * (STRING_SIZE_LIMIT_BYTES - 1), + input=lazy(lambda: "a" * (STRING_SIZE_LIMIT_BYTES - 1)), + expected=lazy(lambda: "a" * (STRING_SIZE_LIMIT_BYTES - 1)), msg="$rtrim should accept input one byte under the size limit", ), # 2-byte chars: one byte under the limit. RtrimTest( "size_one_under_2byte", - input="\u00e9" * ((STRING_SIZE_LIMIT_BYTES - 1) // 2) + "a", - expected="\u00e9" * ((STRING_SIZE_LIMIT_BYTES - 1) // 2) + "a", + input=lazy(lambda: "\u00e9" * ((STRING_SIZE_LIMIT_BYTES - 1) // 2) + "a"), + expected=lazy(lambda: "\u00e9" * ((STRING_SIZE_LIMIT_BYTES - 1) // 2) + "a"), msg="$rtrim should accept 2-byte character input one byte under the size limit", ), # Large input with many trailing trim characters, just under the limit. RtrimTest( "size_trim_trailing", - input="hello" + "a" * (STRING_SIZE_LIMIT_BYTES - 6), + input=lazy(lambda: "hello" + "a" * (STRING_SIZE_LIMIT_BYTES - 6)), chars="a", expected="hello", msg="$rtrim should trim many trailing characters near the size limit", @@ -45,13 +46,13 @@ RTRIM_SIZE_LIMIT_ERROR_TESTS: list[RtrimTest] = [ RtrimTest( "size_at_limit", - input="a" * STRING_SIZE_LIMIT_BYTES, + input=lazy(lambda: "a" * STRING_SIZE_LIMIT_BYTES), error_code=STRING_SIZE_LIMIT_ERROR, msg="$rtrim should reject input at the BSON string byte limit", ), RtrimTest( "size_at_limit_2byte", - input="\u00e9" * (STRING_SIZE_LIMIT_BYTES // 2), + input=lazy(lambda: "\u00e9" * (STRING_SIZE_LIMIT_BYTES // 2)), error_code=STRING_SIZE_LIMIT_ERROR, msg="$rtrim should reject 2-byte character input at the BSON string byte limit", ), diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/string/split/test_split_size_limit.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/split/test_split_size_limit.py index d11c6387d..88f6e3512 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/string/split/test_split_size_limit.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/split/test_split_size_limit.py @@ -7,6 +7,7 @@ execute_expression, ) from documentdb_tests.framework.error_codes import STRING_SIZE_LIMIT_ERROR +from documentdb_tests.framework.lazy_payload import lazy from documentdb_tests.framework.parametrize import pytest_params from documentdb_tests.framework.test_constants import STRING_SIZE_LIMIT_BYTES @@ -19,15 +20,15 @@ SPLIT_SIZE_LIMIT_SUCCESS_TESTS: list[SplitTest] = [ SplitTest( "size_string_one_under", - string="a" * (STRING_SIZE_LIMIT_BYTES - 1), + string=lazy(lambda: "a" * (STRING_SIZE_LIMIT_BYTES - 1)), delimiter="-", - expected=["a" * (STRING_SIZE_LIMIT_BYTES - 1)], + expected=lazy(lambda: ["a" * (STRING_SIZE_LIMIT_BYTES - 1)]), msg="$split should accept input string one byte under the size limit", ), SplitTest( "size_delim_one_under", string="hello", - delimiter="a" * (STRING_SIZE_LIMIT_BYTES - 1), + delimiter=lazy(lambda: "a" * (STRING_SIZE_LIMIT_BYTES - 1)), expected=["hello"], msg="$split should accept delimiter one byte under the size limit", ), @@ -39,7 +40,7 @@ SPLIT_SIZE_LIMIT_ERROR_TESTS: list[SplitTest] = [ SplitTest( "size_limit_input_at_boundary", - string="a" * STRING_SIZE_LIMIT_BYTES, + string=lazy(lambda: "a" * STRING_SIZE_LIMIT_BYTES), delimiter="-", error_code=STRING_SIZE_LIMIT_ERROR, msg="$split should reject input string at the size limit", @@ -47,13 +48,13 @@ SplitTest( "size_limit_delim_at_boundary", string="hello", - delimiter="a" * STRING_SIZE_LIMIT_BYTES, + delimiter=lazy(lambda: "a" * STRING_SIZE_LIMIT_BYTES), error_code=STRING_SIZE_LIMIT_ERROR, msg="$split should reject delimiter at the size limit", ), SplitTest( "size_limit_input_2byte", - string="\u00e9" * (STRING_SIZE_LIMIT_BYTES // 2), + string=lazy(lambda: "\u00e9" * (STRING_SIZE_LIMIT_BYTES // 2)), delimiter="-", error_code=STRING_SIZE_LIMIT_ERROR, msg="$split should reject 2-byte char input totaling the size limit", @@ -61,9 +62,14 @@ # Sub-expression producing oversized result is caught before $split runs. SplitTest( "size_limit_input_subexpr", - string={ - "$concat": ["a" * (STRING_SIZE_LIMIT_BYTES // 2), "a" * (STRING_SIZE_LIMIT_BYTES // 2)] - }, + string=lazy( + lambda: { + "$concat": [ + "a" * (STRING_SIZE_LIMIT_BYTES // 2), + "a" * (STRING_SIZE_LIMIT_BYTES // 2), + ] + } + ), delimiter="-", error_code=STRING_SIZE_LIMIT_ERROR, msg="$split should reject sub-expression producing oversized string", diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/string/strLenBytes/test_strLenBytes_size_limit.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/strLenBytes/test_strLenBytes_size_limit.py index c5417e2aa..49d1f518b 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/string/strLenBytes/test_strLenBytes_size_limit.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/strLenBytes/test_strLenBytes_size_limit.py @@ -7,6 +7,7 @@ execute_expression, ) from documentdb_tests.framework.error_codes import STRING_SIZE_LIMIT_ERROR +from documentdb_tests.framework.lazy_payload import lazy from documentdb_tests.framework.parametrize import pytest_params from documentdb_tests.framework.test_constants import STRING_SIZE_LIMIT_BYTES @@ -19,13 +20,13 @@ STRLENBYTES_SIZE_LIMIT_SUCCESS_TESTS: list[StrLenBytesTest] = [ StrLenBytesTest( "size_one_under", - value="a" * (STRING_SIZE_LIMIT_BYTES - 1), + value=lazy(lambda: "a" * (STRING_SIZE_LIMIT_BYTES - 1)), expected=STRING_SIZE_LIMIT_BYTES - 1, msg="$strLenBytes should handle a string one byte under the size limit", ), StrLenBytesTest( "size_one_under_3byte", - value="寿" * ((STRING_SIZE_LIMIT_BYTES - 1) // 3), + value=lazy(lambda: "寿" * ((STRING_SIZE_LIMIT_BYTES - 1) // 3)), expected=(STRING_SIZE_LIMIT_BYTES - 1) // 3 * 3, msg="$strLenBytes should handle 3-byte chars near the size limit", ), @@ -35,7 +36,7 @@ STRLENBYTES_SIZE_LIMIT_ERROR_TESTS: list[StrLenBytesTest] = [ StrLenBytesTest( "size_at_limit", - value="a" * STRING_SIZE_LIMIT_BYTES, + value=lazy(lambda: "a" * STRING_SIZE_LIMIT_BYTES), error_code=STRING_SIZE_LIMIT_ERROR, msg="$strLenBytes should reject a string at the size limit", ), diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/string/strLenCP/test_strLenCP_size_limit.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/strLenCP/test_strLenCP_size_limit.py index 271ecae74..8b1cf33c3 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/string/strLenCP/test_strLenCP_size_limit.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/strLenCP/test_strLenCP_size_limit.py @@ -7,6 +7,7 @@ execute_expression, ) from documentdb_tests.framework.error_codes import STRING_SIZE_LIMIT_ERROR +from documentdb_tests.framework.lazy_payload import lazy from documentdb_tests.framework.parametrize import pytest_params from documentdb_tests.framework.test_constants import STRING_SIZE_LIMIT_BYTES @@ -19,13 +20,13 @@ STRLENCP_SIZE_LIMIT_SUCCESS_TESTS: list[StrLenCPTest] = [ StrLenCPTest( "size_one_under", - value="a" * (STRING_SIZE_LIMIT_BYTES - 1), + value=lazy(lambda: "a" * (STRING_SIZE_LIMIT_BYTES - 1)), expected=STRING_SIZE_LIMIT_BYTES - 1, msg="$strLenCP should handle a string one byte under the size limit", ), StrLenCPTest( "size_one_under_3byte", - value="寿" * ((STRING_SIZE_LIMIT_BYTES - 1) // 3), + value=lazy(lambda: "寿" * ((STRING_SIZE_LIMIT_BYTES - 1) // 3)), expected=(STRING_SIZE_LIMIT_BYTES - 1) // 3, msg="$strLenCP should count 3-byte chars as code points near the size limit", ), @@ -36,7 +37,7 @@ STRLENCP_SIZE_LIMIT_ERROR_TESTS: list[StrLenCPTest] = [ StrLenCPTest( "size_at_limit", - value="a" * STRING_SIZE_LIMIT_BYTES, + value=lazy(lambda: "a" * STRING_SIZE_LIMIT_BYTES), error_code=STRING_SIZE_LIMIT_ERROR, msg="$strLenCP should reject a string at the size limit", ), diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/string/strcasecmp/test_strcasecmp_size_limit.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/strcasecmp/test_strcasecmp_size_limit.py index 5457ea8db..40e7f2385 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/string/strcasecmp/test_strcasecmp_size_limit.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/strcasecmp/test_strcasecmp_size_limit.py @@ -7,6 +7,7 @@ execute_expression, ) from documentdb_tests.framework.error_codes import STRING_SIZE_LIMIT_ERROR +from documentdb_tests.framework.lazy_payload import lazy from documentdb_tests.framework.parametrize import pytest_params from documentdb_tests.framework.test_constants import STRING_SIZE_LIMIT_BYTES @@ -20,14 +21,14 @@ STRCASECMP_SIZE_LIMIT_SUCCESS_TESTS: list[StrcasecmpTest] = [ StrcasecmpTest( "size_both_half_limit_equal", - string1="a" * ((STRING_SIZE_LIMIT_BYTES - 1) // 2), - string2="a" * ((STRING_SIZE_LIMIT_BYTES - 1) // 2), + string1=lazy(lambda: "a" * ((STRING_SIZE_LIMIT_BYTES - 1) // 2)), + string2=lazy(lambda: "a" * ((STRING_SIZE_LIMIT_BYTES - 1) // 2)), expected=0, msg="$strcasecmp should return 0 for identical large strings", ), StrcasecmpTest( "size_one_under_vs_short", - string1="a" * (STRING_SIZE_LIMIT_BYTES - 1), + string1=lazy(lambda: "a" * (STRING_SIZE_LIMIT_BYTES - 1)), string2="a", expected=1, msg="$strcasecmp should detect length difference with one string near the size limit", @@ -38,7 +39,7 @@ STRCASECMP_SIZE_LIMIT_ERROR_TESTS: list[StrcasecmpTest] = [ StrcasecmpTest( "size_at_limit", - string1="a" * STRING_SIZE_LIMIT_BYTES, + string1=lazy(lambda: "a" * STRING_SIZE_LIMIT_BYTES), string2="a", error_code=STRING_SIZE_LIMIT_ERROR, msg="$strcasecmp should reject a string at the size limit", diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/string/substrBytes/test_substrBytes_size_limit.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/substrBytes/test_substrBytes_size_limit.py index 1558cb958..8d6dc06d9 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/string/substrBytes/test_substrBytes_size_limit.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/substrBytes/test_substrBytes_size_limit.py @@ -7,6 +7,7 @@ execute_expression, ) from documentdb_tests.framework.error_codes import STRING_SIZE_LIMIT_ERROR +from documentdb_tests.framework.lazy_payload import lazy from documentdb_tests.framework.parametrize import pytest_params from documentdb_tests.framework.test_constants import STRING_SIZE_LIMIT_BYTES @@ -20,7 +21,7 @@ SUBSTRBYTES_SIZE_LIMIT_SUCCESS_TESTS: list[SubstrBytesTest] = [ SubstrBytesTest( "size_one_under_first", - string="a" * (STRING_SIZE_LIMIT_BYTES - 1), + string=lazy(lambda: "a" * (STRING_SIZE_LIMIT_BYTES - 1)), byte_index=0, byte_count=1, expected="a", @@ -28,15 +29,15 @@ ), SubstrBytesTest( "size_one_under_full", - string="a" * (STRING_SIZE_LIMIT_BYTES - 1), + string=lazy(lambda: "a" * (STRING_SIZE_LIMIT_BYTES - 1)), byte_index=0, byte_count=STRING_SIZE_LIMIT_BYTES - 1, - expected="a" * (STRING_SIZE_LIMIT_BYTES - 1), + expected=lazy(lambda: "a" * (STRING_SIZE_LIMIT_BYTES - 1)), msg="$substrBytes should extract full string one byte under the 16 MB limit", ), SubstrBytesTest( "size_one_under_last", - string="a" * (STRING_SIZE_LIMIT_BYTES - 1), + string=lazy(lambda: "a" * (STRING_SIZE_LIMIT_BYTES - 1)), byte_index=STRING_SIZE_LIMIT_BYTES - 2, byte_count=1, expected="a", @@ -49,7 +50,7 @@ SUBSTRBYTES_SIZE_LIMIT_ERROR_TESTS: list[SubstrBytesTest] = [ SubstrBytesTest( "size_at_limit", - string="a" * STRING_SIZE_LIMIT_BYTES, + string=lazy(lambda: "a" * STRING_SIZE_LIMIT_BYTES), byte_index=0, byte_count=1, error_code=STRING_SIZE_LIMIT_ERROR, @@ -58,7 +59,7 @@ # 2-byte chars exceeding 16 MB in bytes. SubstrBytesTest( "size_multibyte_at_limit", - string="é" * (STRING_SIZE_LIMIT_BYTES // 2), + string=lazy(lambda: "é" * (STRING_SIZE_LIMIT_BYTES // 2)), byte_index=0, byte_count=1, error_code=STRING_SIZE_LIMIT_ERROR, diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/string/substrCP/test_substrCP_size_limit.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/substrCP/test_substrCP_size_limit.py index 1bb6dbfe7..c0ea8769d 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/string/substrCP/test_substrCP_size_limit.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/substrCP/test_substrCP_size_limit.py @@ -7,6 +7,7 @@ execute_expression, ) from documentdb_tests.framework.error_codes import STRING_SIZE_LIMIT_ERROR +from documentdb_tests.framework.lazy_payload import lazy from documentdb_tests.framework.parametrize import pytest_params from documentdb_tests.framework.test_constants import STRING_SIZE_LIMIT_BYTES @@ -24,7 +25,7 @@ ), SubstrCPTest( "size_one_under", - string="a" * (STRING_SIZE_LIMIT_BYTES - 1), + string=lazy(lambda: "a" * (STRING_SIZE_LIMIT_BYTES - 1)), index=0, count=1, expected="a", @@ -37,21 +38,21 @@ SUBSTRCP_SIZE_LIMIT_ERROR_TESTS: list[SubstrCPTest] = [ SubstrCPTest( "size_at_limit", - string="a" * STRING_SIZE_LIMIT_BYTES, + string=lazy(lambda: "a" * STRING_SIZE_LIMIT_BYTES), error_code=STRING_SIZE_LIMIT_ERROR, msg="$substrCP should reject input string at the 16 MB byte limit", ), # 2-byte chars exceeding 16 MB in bytes. SubstrCPTest( "size_multibyte_at_limit", - string="é" * (STRING_SIZE_LIMIT_BYTES // 2), + string=lazy(lambda: "é" * (STRING_SIZE_LIMIT_BYTES // 2)), error_code=STRING_SIZE_LIMIT_ERROR, msg="$substrCP should reject multi-byte string exceeding 16 MB in bytes", ), # Eager size check: untaken $cond branch with 16 MB string still errors. SubstrCPTest( "size_cond_untaken_branch", - string={"$cond": [True, "hello", "a" * STRING_SIZE_LIMIT_BYTES]}, + string=lazy(lambda: {"$cond": [True, "hello", "a" * STRING_SIZE_LIMIT_BYTES]}), error_code=STRING_SIZE_LIMIT_ERROR, msg="$substrCP should reject 16 MB string in untaken $cond branch", ), diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/string/toLower/test_toLower_size_limit.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/toLower/test_toLower_size_limit.py index 37702891e..69a849426 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/string/toLower/test_toLower_size_limit.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/toLower/test_toLower_size_limit.py @@ -7,6 +7,7 @@ execute_expression, ) from documentdb_tests.framework.error_codes import STRING_SIZE_LIMIT_ERROR +from documentdb_tests.framework.lazy_payload import lazy from documentdb_tests.framework.parametrize import pytest_params from documentdb_tests.framework.test_constants import STRING_SIZE_LIMIT_BYTES @@ -19,8 +20,8 @@ TOLOWER_SIZE_LIMIT_SUCCESS_TESTS: list[ToLowerTest] = [ ToLowerTest( "size_one_under", - value="A" * (STRING_SIZE_LIMIT_BYTES - 1), - expected="a" * (STRING_SIZE_LIMIT_BYTES - 1), + value=lazy(lambda: "A" * (STRING_SIZE_LIMIT_BYTES - 1)), + expected=lazy(lambda: "a" * (STRING_SIZE_LIMIT_BYTES - 1)), msg="$toLower should accept input string one byte under the 16 MB limit", ), ] @@ -29,7 +30,7 @@ TOLOWER_SIZE_LIMIT_ERROR_TESTS: list[ToLowerTest] = [ ToLowerTest( "size_at_limit", - value="a" * STRING_SIZE_LIMIT_BYTES, + value=lazy(lambda: "a" * STRING_SIZE_LIMIT_BYTES), error_code=STRING_SIZE_LIMIT_ERROR, msg="$toLower should reject input string at the 16 MB byte limit", ), diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/string/toUpper/test_toUpper_size_limit.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/toUpper/test_toUpper_size_limit.py index f63bfb917..3734fe9c7 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/string/toUpper/test_toUpper_size_limit.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/toUpper/test_toUpper_size_limit.py @@ -7,6 +7,7 @@ execute_expression, ) from documentdb_tests.framework.error_codes import STRING_SIZE_LIMIT_ERROR +from documentdb_tests.framework.lazy_payload import lazy from documentdb_tests.framework.parametrize import pytest_params from documentdb_tests.framework.test_constants import STRING_SIZE_LIMIT_BYTES @@ -19,8 +20,8 @@ TOUPPER_SIZE_LIMIT_SUCCESS_TESTS: list[ToUpperTest] = [ ToUpperTest( "size_one_under", - value="a" * (STRING_SIZE_LIMIT_BYTES - 1), - expected="A" * (STRING_SIZE_LIMIT_BYTES - 1), + value=lazy(lambda: "a" * (STRING_SIZE_LIMIT_BYTES - 1)), + expected=lazy(lambda: "A" * (STRING_SIZE_LIMIT_BYTES - 1)), msg="$toUpper should accept input string one byte under the 16 MB limit", ), ] @@ -29,7 +30,7 @@ TOUPPER_SIZE_LIMIT_ERROR_TESTS: list[ToUpperTest] = [ ToUpperTest( "size_at_limit", - value="a" * STRING_SIZE_LIMIT_BYTES, + value=lazy(lambda: "a" * STRING_SIZE_LIMIT_BYTES), error_code=STRING_SIZE_LIMIT_ERROR, msg="$toUpper should reject input string at the 16 MB byte limit", ), diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/string/trim/test_trim_size_limit.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/trim/test_trim_size_limit.py index df781ff97..4ae6f97ab 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/string/trim/test_trim_size_limit.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/trim/test_trim_size_limit.py @@ -7,6 +7,7 @@ execute_expression, ) from documentdb_tests.framework.error_codes import STRING_SIZE_LIMIT_ERROR +from documentdb_tests.framework.lazy_payload import lazy from documentdb_tests.framework.parametrize import pytest_params from documentdb_tests.framework.test_constants import STRING_SIZE_LIMIT_BYTES @@ -19,22 +20,24 @@ TRIM_SIZE_LIMIT_SUCCESS_TESTS: list[TrimTest] = [ TrimTest( "size_one_under", - input="a" * (STRING_SIZE_LIMIT_BYTES - 1), - expected="a" * (STRING_SIZE_LIMIT_BYTES - 1), + input=lazy(lambda: "a" * (STRING_SIZE_LIMIT_BYTES - 1)), + expected=lazy(lambda: "a" * (STRING_SIZE_LIMIT_BYTES - 1)), msg="$trim should accept input one byte under the size limit", ), TrimTest( "size_one_under_2byte", - input="\u00e9" * ((STRING_SIZE_LIMIT_BYTES - 1) // 2) + "a", - expected="\u00e9" * ((STRING_SIZE_LIMIT_BYTES - 1) // 2) + "a", + input=lazy(lambda: "\u00e9" * ((STRING_SIZE_LIMIT_BYTES - 1) // 2) + "a"), + expected=lazy(lambda: "\u00e9" * ((STRING_SIZE_LIMIT_BYTES - 1) // 2) + "a"), msg="$trim should accept 2-byte character input one byte under the size limit", ), # Large input with many leading and trailing trim characters, just under the limit. TrimTest( "size_trim_both_sides", - input="a" * ((STRING_SIZE_LIMIT_BYTES - 6) // 2) - + "hello" - + "a" * ((STRING_SIZE_LIMIT_BYTES - 6) // 2), + input=lazy( + lambda: "a" * ((STRING_SIZE_LIMIT_BYTES - 6) // 2) + + "hello" + + "a" * ((STRING_SIZE_LIMIT_BYTES - 6) // 2) + ), chars="a", expected="hello", msg="$trim should trim many characters from both sides near the size limit", @@ -46,13 +49,13 @@ TRIM_SIZE_LIMIT_ERROR_TESTS: list[TrimTest] = [ TrimTest( "size_at_limit", - input="a" * STRING_SIZE_LIMIT_BYTES, + input=lazy(lambda: "a" * STRING_SIZE_LIMIT_BYTES), error_code=STRING_SIZE_LIMIT_ERROR, msg="$trim should reject input at the BSON string byte limit", ), TrimTest( "size_at_limit_2byte", - input="\u00e9" * (STRING_SIZE_LIMIT_BYTES // 2), + input=lazy(lambda: "\u00e9" * (STRING_SIZE_LIMIT_BYTES // 2)), error_code=STRING_SIZE_LIMIT_ERROR, msg="$trim should reject 2-byte character input at the BSON string byte limit", ), diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/type/convert/test_convert_on_error.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/convert/test_convert_on_error.py index 2a7c82800..aad4ccff9 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/type/convert/test_convert_on_error.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/convert/test_convert_on_error.py @@ -11,6 +11,7 @@ assert_expression_result, execute_expression, ) +from documentdb_tests.framework.lazy_payload import lazy from documentdb_tests.framework.parametrize import pytest_params from documentdb_tests.framework.test_constants import ( DECIMAL128_NAN, @@ -99,7 +100,7 @@ ), ConvertTest( "on_error_catches_string_size_limit", - input=Binary(b"A" * (STRING_SIZE_LIMIT_BYTES // 2)), + input=lazy(lambda: Binary(b"A" * (STRING_SIZE_LIMIT_BYTES // 2))), to="string", format="hex", on_error="caught", diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/type/convert/test_convert_size_limit.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/convert/test_convert_size_limit.py index d15dd8f49..0ff5fab41 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/type/convert/test_convert_size_limit.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/convert/test_convert_size_limit.py @@ -12,6 +12,7 @@ execute_expression, ) from documentdb_tests.framework.error_codes import CONVERSION_FAILURE_ERROR, STRING_SIZE_LIMIT_ERROR +from documentdb_tests.framework.lazy_payload import lazy from documentdb_tests.framework.parametrize import pytest_params from documentdb_tests.framework.test_constants import STRING_SIZE_LIMIT_BYTES @@ -20,10 +21,10 @@ CONVERT_STRING_SIZE_LIMIT_SUCCESS_TESTS: list[ConvertTest] = [ ConvertTest( "size_limit_hex_under", - input=Binary(b"A" * (STRING_SIZE_LIMIT_BYTES // 2 - 1)), + input=lazy(lambda: Binary(b"A" * (STRING_SIZE_LIMIT_BYTES // 2 - 1))), to="string", format="hex", - expected="41" * (STRING_SIZE_LIMIT_BYTES // 2 - 1), + expected=lazy(lambda: "41" * (STRING_SIZE_LIMIT_BYTES // 2 - 1)), msg=( "$convert should succeed for BinData-to-string when output is" " just under the size limit" @@ -37,7 +38,7 @@ # hex format: 8_388_608 bytes -> 16_777_216 chars (exactly the limit). ConvertTest( "size_limit_err_hex_at_limit", - input=Binary(b"A" * (STRING_SIZE_LIMIT_BYTES // 2)), + input=lazy(lambda: Binary(b"A" * (STRING_SIZE_LIMIT_BYTES // 2))), to="string", format="hex", error_code=CONVERSION_FAILURE_ERROR, @@ -46,7 +47,7 @@ # base64 format: 12_582_912 bytes -> 16_777_216 chars (exactly the limit). ConvertTest( "size_limit_err_base64_at_limit", - input=Binary(b"A" * (STRING_SIZE_LIMIT_BYTES * 3 // 4)), + input=lazy(lambda: Binary(b"A" * (STRING_SIZE_LIMIT_BYTES * 3 // 4))), to="string", format="base64", error_code=CONVERSION_FAILURE_ERROR, @@ -56,7 +57,7 @@ # error code than other formats. ConvertTest( "size_limit_err_utf8_at_limit", - input=Binary(b"A" * STRING_SIZE_LIMIT_BYTES), + input=lazy(lambda: Binary(b"A" * STRING_SIZE_LIMIT_BYTES)), to="string", format="utf8", error_code=STRING_SIZE_LIMIT_ERROR, @@ -64,7 +65,7 @@ ), ConvertTest( "size_limit_err_oversized_input", - input="A" * STRING_SIZE_LIMIT_BYTES, + input=lazy(lambda: "A" * STRING_SIZE_LIMIT_BYTES), to="int", error_code=STRING_SIZE_LIMIT_ERROR, msg="$convert should reject oversized string in input parameter", @@ -72,21 +73,21 @@ ConvertTest( "size_limit_err_oversized_to", input=42, - to="A" * STRING_SIZE_LIMIT_BYTES, + to=lazy(lambda: "A" * STRING_SIZE_LIMIT_BYTES), error_code=STRING_SIZE_LIMIT_ERROR, msg="$convert should reject oversized string in to parameter", ), ConvertTest( "size_limit_err_oversized_to_type", input=42, - to={"type": "A" * STRING_SIZE_LIMIT_BYTES}, + to=lazy(lambda: {"type": "A" * STRING_SIZE_LIMIT_BYTES}), error_code=STRING_SIZE_LIMIT_ERROR, msg="$convert should reject oversized string in to.type parameter", ), ConvertTest( "size_limit_err_oversized_to_subtype", input=42, - to={"type": "binData", "subtype": "A" * STRING_SIZE_LIMIT_BYTES}, + to=lazy(lambda: {"type": "binData", "subtype": "A" * STRING_SIZE_LIMIT_BYTES}), error_code=STRING_SIZE_LIMIT_ERROR, msg="$convert should reject oversized string in to.subtype parameter", ), @@ -94,7 +95,7 @@ "size_limit_err_oversized_format", input=42, to="string", - format="A" * STRING_SIZE_LIMIT_BYTES, + format=lazy(lambda: "A" * STRING_SIZE_LIMIT_BYTES), error_code=STRING_SIZE_LIMIT_ERROR, msg="$convert should reject oversized string in format parameter", ), @@ -102,7 +103,7 @@ "size_limit_err_oversized_byte_order", input=42, to="binData", - byte_order="A" * STRING_SIZE_LIMIT_BYTES, + byte_order=lazy(lambda: "A" * STRING_SIZE_LIMIT_BYTES), error_code=STRING_SIZE_LIMIT_ERROR, msg="$convert should reject oversized string in byteOrder parameter", ), diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/utils/utils.py b/documentdb_tests/compatibility/tests/core/operator/expressions/utils/utils.py index fa290ebdf..ec6d21c75 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/utils/utils.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/utils/utils.py @@ -7,6 +7,7 @@ from documentdb_tests.framework.assertions import assertResult from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.lazy_payload import materialize def build_nested_expr(value, operator, depth): @@ -56,7 +57,7 @@ def execute_project(collection, project): "aggregate": 1, "pipeline": [ {"$documents": [{}]}, - {"$project": {**project, "_id": 0}}, + {"$project": {**materialize(project), "_id": 0}}, ], "cursor": {}, }, @@ -84,13 +85,13 @@ def execute_project_with_insert(collection, document, project): ... ) # Returns result with {"quotient": 3.33...} in firstBatch """ - collection.insert_one(document or {}) + collection.insert_one(materialize(document) or {}) return execute_command( collection, { "aggregate": collection.name, "pipeline": [ - {"$project": {**project, "_id": 0}}, + {"$project": {**materialize(project), "_id": 0}}, ], "cursor": {}, }, @@ -145,7 +146,7 @@ def execute_expression_with_insert(collection, expression, document): Result from execute_command with structure: {"cursor": {"firstBatch": [{"result": }]}} """ - collection.insert_one(document or {}) + collection.insert_one(materialize(document) or {}) return execute_command( collection, { diff --git a/documentdb_tests/compatibility/tests/core/operator/query/arrays/all/test_all_matching_behavior.py b/documentdb_tests/compatibility/tests/core/operator/query/arrays/all/test_all_matching_behavior.py index e299c05d8..847338f1b 100644 --- a/documentdb_tests/compatibility/tests/core/operator/query/arrays/all/test_all_matching_behavior.py +++ b/documentdb_tests/compatibility/tests/core/operator/query/arrays/all/test_all_matching_behavior.py @@ -16,6 +16,7 @@ ) from documentdb_tests.framework.assertions import assertSuccess from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.lazy_payload import lazy from documentdb_tests.framework.parametrize import pytest_params SCALAR_MATCHING_TESTS: list[QueryTestCase] = [ @@ -388,9 +389,9 @@ def test_all_null_and_missing(collection, test): LARGE_ARRAY_TESTS: list[QueryTestCase] = [ QueryTestCase( id="10000_elements_match", - filter={"a": {"$all": list(range(10000))}}, + filter=lazy(lambda: {"a": {"$all": list(range(10000))}}), doc=[{"_id": 1, "a": list(range(10000))}], - expected=[{"_id": 1, "a": list(range(10000))}], + expected=lazy(lambda: [{"_id": 1, "a": list(range(10000))}]), msg="$all with 10000 elements should match field with 10000 matching elements", ), QueryTestCase( diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/count/test_count_behavior.py b/documentdb_tests/compatibility/tests/core/operator/stages/count/test_count_behavior.py index f4b7c3a89..be5e3d157 100644 --- a/documentdb_tests/compatibility/tests/core/operator/stages/count/test_count_behavior.py +++ b/documentdb_tests/compatibility/tests/core/operator/stages/count/test_count_behavior.py @@ -10,6 +10,7 @@ ) from documentdb_tests.framework.assertions import assertResult from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.lazy_payload import lazy from documentdb_tests.framework.parametrize import pytest_params # Property [Core Counting Behavior]: the output is exactly one document whose @@ -31,7 +32,7 @@ ), StageTestCase( "core_large_collection", - docs=[{"_id": i} for i in range(10_000)], + docs=lazy(lambda: [{"_id": i} for i in range(10_000)]), pipeline=[{"$count": "total"}], expected=[{"total": 10_000}], msg="$count should return correct count for a large number of documents", @@ -90,7 +91,7 @@ ), StageTestCase( "return_type_multiple", - docs=[{"_id": i} for i in range(10_000)], + docs=lazy(lambda: [{"_id": i} for i in range(10_000)]), pipeline=[ {"$count": "n"}, {"$addFields": {"type": {"$type": "$n"}}}, diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_result_size.py b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_result_size.py index bc547278c..e7e225790 100644 --- a/documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_result_size.py +++ b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_result_size.py @@ -12,6 +12,7 @@ ) from documentdb_tests.framework.assertions import assertResult from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.lazy_payload import lazy from documentdb_tests.framework.parametrize import pytest_params # Property [Large Result Sets]: joining many matching documents succeeds @@ -39,7 +40,9 @@ LookupTestCase( "joined_array_exceeds_16mb", docs=[{"_id": 1, "lf": "m"}], - foreign_docs=[{"_id": i, "ff": "m", "data": "x" * 1_000_000} for i in range(20)], + foreign_docs=lazy( + lambda: [{"_id": i, "ff": "m", "data": "x" * 1_000_000} for i in range(20)] + ), pipeline=[ { "$lookup": { diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/lookup/utils/lookup_common.py b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/utils/lookup_common.py index 1b5b40a1c..e278a84d0 100644 --- a/documentdb_tests/compatibility/tests/core/operator/stages/lookup/utils/lookup_common.py +++ b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/utils/lookup_common.py @@ -10,6 +10,7 @@ from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( StageTestCase, ) +from documentdb_tests.framework.lazy_payload import materialize FOREIGN = object() @@ -38,13 +39,13 @@ def setup_lookup( if test_case.docs is not None: db.create_collection(collection.name) if test_case.docs: - collection.insert_many(test_case.docs) + collection.insert_many(materialize(test_case.docs)) # Set up foreign collection if test_case.foreign_docs is not None: db.create_collection(foreign_name) if test_case.foreign_docs: - db[foreign_name].insert_many(test_case.foreign_docs) + db[foreign_name].insert_many(materialize(test_case.foreign_docs)) if test_case.foreign_indexes: db[foreign_name].create_indexes(test_case.foreign_indexes) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/out/test_out_write_properties.py b/documentdb_tests/compatibility/tests/core/operator/stages/out/test_out_write_properties.py index 26aeefe18..55bd81ef3 100644 --- a/documentdb_tests/compatibility/tests/core/operator/stages/out/test_out_write_properties.py +++ b/documentdb_tests/compatibility/tests/core/operator/stages/out/test_out_write_properties.py @@ -29,6 +29,7 @@ assertSuccess, ) from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.lazy_payload import lazy from documentdb_tests.framework.parametrize import pytest_params # Property [Write Behavior - Auto-Generated _id]: documents with _id removed @@ -266,7 +267,7 @@ def test_out_bson_round_trip(collection, test_case: OutTestCase): OUT_LARGE_DOCUMENT_TESTS: list[OutTestCase] = [ OutTestCase( "large_doc", - docs=[{"_id": 1, "data": "x" * (15 * 1_024 * 1_024)}], + docs=lazy(lambda: [{"_id": 1, "data": "x" * (15 * 1_024 * 1_024)}]), expected=[{"_id": 1}], msg="$out should successfully write a 15 MB document", ), diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/redact/test_redact_descend_structure.py b/documentdb_tests/compatibility/tests/core/operator/stages/redact/test_redact_descend_structure.py index 37a9505e3..5cc4437f3 100644 --- a/documentdb_tests/compatibility/tests/core/operator/stages/redact/test_redact_descend_structure.py +++ b/documentdb_tests/compatibility/tests/core/operator/stages/redact/test_redact_descend_structure.py @@ -16,6 +16,7 @@ ) from documentdb_tests.framework.assertions import assertResult from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.lazy_payload import lazy from documentdb_tests.framework.parametrize import pytest_params # Property [Recursion Depth and Scale]: $redact descends through deep nesting and @@ -23,9 +24,13 @@ REDACT_STRUCTURAL_TESTS: list[StageTestCase] = [ StageTestCase( "structural_large_array_descended_element_wise", - docs=[{"_id": 1, "items": [{"n": i, "sub": {"secret": True}} for i in range(10_000)]}], + docs=lazy( + lambda: [ + {"_id": 1, "items": [{"n": i, "sub": {"secret": True}} for i in range(10_000)]} + ] + ), pipeline=[{"$redact": {"$cond": [{"$eq": ["$secret", True]}, "$$PRUNE", "$$DESCEND"]}}], - expected=[{"_id": 1, "items": [{"n": i} for i in range(10_000)]}], + expected=lazy(lambda: [{"_id": 1, "items": [{"n": i} for i in range(10_000)]}]), msg="$redact under $$DESCEND should descend element-wise into a large array of " "embedded documents without error", ), diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/replaceRoot/test_replaceRoot_limits.py b/documentdb_tests/compatibility/tests/core/operator/stages/replaceRoot/test_replaceRoot_limits.py index 78b574dbc..1f89e01dd 100644 --- a/documentdb_tests/compatibility/tests/core/operator/stages/replaceRoot/test_replaceRoot_limits.py +++ b/documentdb_tests/compatibility/tests/core/operator/stages/replaceRoot/test_replaceRoot_limits.py @@ -24,6 +24,7 @@ OVERFLOW_ERROR, ) from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.lazy_payload import lazy from documentdb_tests.framework.parametrize import pytest_params @@ -61,7 +62,7 @@ def _nest(depth: int, leaf: Any) -> Any: REPLACEROOT_SIZE_BOUNDARY_TESTS: list[StageTestCase] = [ StageTestCase( "size_boundary_max_output_accepted", - docs=[{"_id": 1, "data": {"s": "x" * BIG_STORED_STRING_BYTES}}], + docs=lazy(lambda: [{"_id": 1, "data": {"s": "x" * BIG_STORED_STRING_BYTES}}]), pipeline=[ { "$replaceRoot": { @@ -95,7 +96,7 @@ def _nest(depth: int, leaf: Any) -> Any: REPLACEROOT_SIZE_LIMIT_ERROR_TESTS: list[StageTestCase] = [ StageTestCase( "size_limit_one_over_rejected", - docs=[{"_id": 1, "data": {"s": "x" * BIG_STORED_STRING_BYTES}}], + docs=lazy(lambda: [{"_id": 1, "data": {"s": "x" * BIG_STORED_STRING_BYTES}}]), pipeline=[ { "$replaceRoot": { diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/replaceWith/test_replaceWith_limits.py b/documentdb_tests/compatibility/tests/core/operator/stages/replaceWith/test_replaceWith_limits.py index 26801d609..5d2b5291f 100644 --- a/documentdb_tests/compatibility/tests/core/operator/stages/replaceWith/test_replaceWith_limits.py +++ b/documentdb_tests/compatibility/tests/core/operator/stages/replaceWith/test_replaceWith_limits.py @@ -23,6 +23,7 @@ OVERFLOW_ERROR, ) from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.lazy_payload import lazy from documentdb_tests.framework.parametrize import pytest_params @@ -59,7 +60,7 @@ def _nest(depth: int, leaf: Any) -> Any: REPLACEWITH_SIZE_BOUNDARY_TESTS: list[StageTestCase] = [ StageTestCase( "size_boundary_max_output_accepted", - docs=[{"_id": 1, "data": {"s": "x" * BIG_STORED_STRING_BYTES}}], + docs=lazy(lambda: [{"_id": 1, "data": {"s": "x" * BIG_STORED_STRING_BYTES}}]), pipeline=[ {"$replaceWith": {"$mergeObjects": ["$data", {"pad": "y" * BOUNDARY_PAD_BYTES}]}}, {"$project": {"_id": 0, "size": {"$bsonSize": "$$ROOT"}}}, @@ -87,7 +88,7 @@ def _nest(depth: int, leaf: Any) -> Any: REPLACEWITH_SIZE_LIMIT_ERROR_TESTS: list[StageTestCase] = [ StageTestCase( "size_limit_one_over_rejected", - docs=[{"_id": 1, "data": {"s": "x" * BIG_STORED_STRING_BYTES}}], + docs=lazy(lambda: [{"_id": 1, "data": {"s": "x" * BIG_STORED_STRING_BYTES}}]), pipeline=[ {"$replaceWith": {"$mergeObjects": ["$data", {"pad": "y" * OVER_LIMIT_PAD_BYTES}]}} ], diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/test_stages_position_lookup.py b/documentdb_tests/compatibility/tests/core/operator/stages/test_stages_position_lookup.py index aaff93e22..7f2ddab04 100644 --- a/documentdb_tests/compatibility/tests/core/operator/stages/test_stages_position_lookup.py +++ b/documentdb_tests/compatibility/tests/core/operator/stages/test_stages_position_lookup.py @@ -13,6 +13,7 @@ ) from documentdb_tests.framework.assertions import assertResult from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.lazy_payload import lazy from documentdb_tests.framework.parametrize import pytest_params from documentdb_tests.framework.test_constants import DOUBLE_ZERO @@ -256,7 +257,9 @@ LookupTestCase( "lookup_then_unwind_exceeds_16mb", docs=[{"_id": 1, "lf": "m"}], - foreign_docs=[{"_id": i, "ff": "m", "data": "x" * 1_000_000} for i in range(20)], + foreign_docs=lazy( + lambda: [{"_id": i, "ff": "m", "data": "x" * 1_000_000} for i in range(20)] + ), pipeline=[ { "$lookup": { diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/unset/test_unset_paths.py b/documentdb_tests/compatibility/tests/core/operator/stages/unset/test_unset_paths.py index fe9165560..fbc043255 100644 --- a/documentdb_tests/compatibility/tests/core/operator/stages/unset/test_unset_paths.py +++ b/documentdb_tests/compatibility/tests/core/operator/stages/unset/test_unset_paths.py @@ -13,6 +13,7 @@ ) from documentdb_tests.framework.assertions import assertResult from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.lazy_payload import lazy from documentdb_tests.framework.parametrize import pytest_params # Property [Dotted Path Removal]: dot notation removes a nested field within a @@ -137,10 +138,12 @@ UNSET_LARGE_FIELD_COUNT_TESTS: list[StageTestCase] = [ StageTestCase( "large_field_count_array_form", - docs=[ - {"_id": 1, **{f"f{i}": i for i in range(10_000)}}, - ], - pipeline=[{"$unset": [f"f{i}" for i in range(10_000)]}], + docs=lazy( + lambda: [ + {"_id": 1, **{f"f{i}": i for i in range(10_000)}}, + ] + ), + pipeline=lazy(lambda: [{"$unset": [f"f{i}" for i in range(10_000)]}]), expected=[{"_id": 1}], msg="$unset should handle a large number of fields in the array form", ), diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/utils/stage_test_case.py b/documentdb_tests/compatibility/tests/core/operator/stages/utils/stage_test_case.py index 0fa6bb76e..1b1e7e3e7 100644 --- a/documentdb_tests/compatibility/tests/core/operator/stages/utils/stage_test_case.py +++ b/documentdb_tests/compatibility/tests/core/operator/stages/utils/stage_test_case.py @@ -11,6 +11,7 @@ from pymongo.collection import Collection from pymongo.operations import IndexModel +from documentdb_tests.framework.lazy_payload import materialize from documentdb_tests.framework.target_collection import TargetCollection from documentdb_tests.framework.test_case import BaseTestCase @@ -48,7 +49,7 @@ def populate_collection(collection: Collection, test_case: StageTestCase) -> Col writable = test_case.target_collection.writable(collection, coll) if test_case.docs: - writable.insert_many(test_case.docs) + writable.insert_many(materialize(test_case.docs)) if test_case.indexes: writable.create_indexes(test_case.indexes) return coll diff --git a/documentdb_tests/compatibility/tests/core/operator/system-stages/listLocalSessions/test_listLocalSessions_success.py b/documentdb_tests/compatibility/tests/core/operator/system-stages/listLocalSessions/test_listLocalSessions_success.py index 0ef6e4f33..6a501ce37 100644 --- a/documentdb_tests/compatibility/tests/core/operator/system-stages/listLocalSessions/test_listLocalSessions_success.py +++ b/documentdb_tests/compatibility/tests/core/operator/system-stages/listLocalSessions/test_listLocalSessions_success.py @@ -10,6 +10,7 @@ ) from documentdb_tests.framework.assertions import assertResult from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.lazy_payload import lazy from documentdb_tests.framework.parametrize import pytest_params from documentdb_tests.framework.property_checks import Eq @@ -161,13 +162,15 @@ ), StageTestCase( "users_large_array_10000", - pipeline=[ - { - "$listLocalSessions": { - "users": [{"user": f"u{i}", "db": "admin"} for i in range(10_000)] + pipeline=lazy( + lambda: [ + { + "$listLocalSessions": { + "users": [{"user": f"u{i}", "db": "admin"} for i in range(10_000)] + } } - } - ], + ] + ), expected={"ok": Eq(1.0), "cursor": {"firstBatch": Eq([])}}, msg="$listLocalSessions should accept a large users array", ), @@ -217,7 +220,9 @@ ), StageTestCase( "user_long", - pipeline=[{"$listLocalSessions": {"users": [{"user": "x" * 10_000, "db": "admin"}]}}], + pipeline=lazy( + lambda: [{"$listLocalSessions": {"users": [{"user": "x" * 10_000, "db": "admin"}]}}] + ), expected={"ok": Eq(1.0), "cursor": {"firstBatch": Eq([])}}, msg="$listLocalSessions should accept a very long user with no " "operator-specific length limit", diff --git a/documentdb_tests/compatibility/tests/core/utils/command_test_case.py b/documentdb_tests/compatibility/tests/core/utils/command_test_case.py index c4785c54e..2abeefd3f 100644 --- a/documentdb_tests/compatibility/tests/core/utils/command_test_case.py +++ b/documentdb_tests/compatibility/tests/core/utils/command_test_case.py @@ -10,6 +10,7 @@ from pymongo.collection import Collection from pymongo.database import Database +from documentdb_tests.framework.lazy_payload import materialize from documentdb_tests.framework.target_collection import ( SiblingCollection, TargetCollection, @@ -106,7 +107,7 @@ def prepare(self, db: Database, collection: Collection) -> Collection: if target.name not in target.database.list_collection_names(): target.database.create_collection(target.name) if self.docs: - target.insert_many(self.docs) + target.insert_many(materialize(self.docs)) if self.siblings: for sibling in self.siblings: sibling.create(db, resolved) diff --git a/documentdb_tests/conftest.py b/documentdb_tests/conftest.py index 90bf88938..ad11014b2 100644 --- a/documentdb_tests/conftest.py +++ b/documentdb_tests/conftest.py @@ -25,6 +25,10 @@ from documentdb_tests.framework.error_codes_validator import ( # noqa: E402 validate_error_codes_sorted, ) +from documentdb_tests.framework.large_payload_guard import ( # noqa: E402 + PARAM_SIZE_LIMIT_BYTES, + exceeds_size_limit, +) from documentdb_tests.framework.preconditions import ( # noqa: E402 REQUIRES_MARKER, detect_capabilities, @@ -410,7 +414,23 @@ def pytest_collection_modifyitems(session, config, items): # Validate framework error code invariants structure_errors.extend(validate_error_codes_sorted()) - if structure_errors or format_errors: + # No test may embed a large payload in its parametrized data. Such values are + # duplicated across xdist workers and held for the whole session; they belong + # behind Lazy so they are built at test time. + large_param_errors = [] + for item in items: + callspec = getattr(item, "callspec", None) + if callspec is None: + continue + for value in callspec.params.values(): + if exceeds_size_limit(value): + large_param_errors.append( + f" {item.nodeid} exceeds the {PARAM_SIZE_LIMIT_BYTES:,}-byte " + "parametrized-data limit" + ) + break + + if structure_errors or format_errors or large_param_errors: import sys if structure_errors: @@ -425,6 +445,15 @@ def pytest_collection_modifyitems(session, config, items): print("\n".join(file_errors), file=sys.stderr) print("\nSee docs/testing/TEST_FORMAT.md for rules.\n", file=sys.stderr) + if large_param_errors: + print("\n❌ Large Test Payloads:", file=sys.stderr) + print("\n".join(large_param_errors), file=sys.stderr) + print( + "\nWrap large values in lazy(...) so they are built at test time " + "instead of held in the collected test data.\n", + file=sys.stderr, + ) + pytest.exit("Test validation failed", returncode=1) diff --git a/documentdb_tests/framework/assertions.py b/documentdb_tests/framework/assertions.py index b4326463e..ebd12e287 100644 --- a/documentdb_tests/framework/assertions.py +++ b/documentdb_tests/framework/assertions.py @@ -11,6 +11,7 @@ from bson import Decimal128, Int64 from documentdb_tests.framework.infra_exceptions import INFRA_EXCEPTION_TYPES as _INFRA_TYPES +from documentdb_tests.framework.lazy_payload import materialize from documentdb_tests.framework.property_checks import _FIELD_ABSENT, Check, PerDoc _MAX_REPR_LEN = 1000 @@ -165,6 +166,7 @@ def assertSuccess( transform: Optional callback to transform result before comparison ignore_doc_order: If True, compare lists ignoring order (duplicates still matter) """ + expected = materialize(expected) if isinstance(result, Exception): if isinstance(result, _INFRA_TYPES): raise result @@ -338,6 +340,7 @@ def assertResult( assertResult(result, expected=[{"r": [3, 1, 2]}], ignore_order_in=["r"]) assertResult(result, expected={"ok": 1.0}, raw_res=True) # Raw command result """ + expected = materialize(expected) if error_code is not None: assertFailureCode(result, error_code, msg) elif isinstance(expected, PerDoc) or ( diff --git a/documentdb_tests/framework/executor.py b/documentdb_tests/framework/executor.py index 49febfa3b..e028b4a11 100644 --- a/documentdb_tests/framework/executor.py +++ b/documentdb_tests/framework/executor.py @@ -8,6 +8,8 @@ from bson.codec_options import CodecOptions +from documentdb_tests.framework.lazy_payload import materialize + TZ_AWARE_CODEC: CodecOptions = CodecOptions(tz_aware=True, tzinfo=timezone.utc) @@ -27,7 +29,7 @@ def execute_command(collection, command: Dict, codec_options=TZ_AWARE_CODEC, ses """ try: db = collection.database - result = db.command(command, codec_options=codec_options, session=session) + result = db.command(materialize(command), codec_options=codec_options, session=session) return result except Exception as e: return e @@ -47,7 +49,7 @@ def execute_admin_command(collection, command: Dict, session=None) -> Any: """ try: db = collection.database.client.admin - result = db.command(command, session=session) + result = db.command(materialize(command), session=session) return result except Exception as e: return e diff --git a/documentdb_tests/framework/large_payload_guard.py b/documentdb_tests/framework/large_payload_guard.py new file mode 100644 index 000000000..6b53859e9 --- /dev/null +++ b/documentdb_tests/framework/large_payload_guard.py @@ -0,0 +1,53 @@ +"""Detection of oversized test payloads. + +A test's parametrized data is kept by pytest for the whole session and copied +onto every xdist worker, so a large value embedded there is a heavy, needless +footprint. This measures a value's size so the collection guardrail can reject +such tests and point them at ``lazy``, which defers construction to test time. +""" + +from __future__ import annotations + +import sys +from typing import Any + +from documentdb_tests.framework.lazy_payload import Lazy + +# A test's parametrized data should not embed a payload larger than this. +PARAM_SIZE_LIMIT_BYTES = 1_000_000 + + +def _deep_size(value: Any, seen: set[int], budget: int) -> int: + """Sum the byte size of ``value``, stopping early once it exceeds ``budget``. + + A ``Lazy`` is treated as its small placeholder, not the value it would build, + so deferred payloads count as tiny. + """ + if isinstance(value, Lazy) or id(value) in seen: + return 0 + seen.add(id(value)) + total = sys.getsizeof(value, 0) + if isinstance(value, (str, bytes, bytearray)): + return total + if isinstance(value, dict): + for key, item in value.items(): + total += _deep_size(key, seen, budget) + _deep_size(item, seen, budget) + if total > budget: + return total + elif isinstance(value, (list, tuple, set, frozenset)): + for item in value: + total += _deep_size(item, seen, budget) + if total > budget: + return total + elif hasattr(value, "__dict__"): + total += _deep_size(vars(value), seen, budget) + return total + + +def exceeds_size_limit(value: Any) -> bool: + """Return whether ``value``'s byte size exceeds ``PARAM_SIZE_LIMIT_BYTES``. + + Sizing stops as soon as the limit is passed, so ordinary small values cost + only a shallow walk. + """ + return _deep_size(value, set(), PARAM_SIZE_LIMIT_BYTES) > PARAM_SIZE_LIMIT_BYTES diff --git a/documentdb_tests/framework/lazy_payload.py b/documentdb_tests/framework/lazy_payload.py new file mode 100644 index 000000000..b3ed2f4a8 --- /dev/null +++ b/documentdb_tests/framework/lazy_payload.py @@ -0,0 +1,70 @@ +"""Deferred construction of large test payloads. + +Some tests operate on multi-megabyte strings and byte buffers. Building those +values directly in the parametrized test data keeps every copy in memory for the +whole session, on every xdist worker, which is a large and needless footprint. + +Wrap the large value in ``Lazy`` so the test data holds only a small builder. +The framework calls ``materialize`` where it consumes the value (building the +command and comparing the expected result), so the large value is created only +while its own test runs and is freed afterward. Plain values pass through +``materialize`` untouched, so wrapping is needed only on the large fields. +""" + +from __future__ import annotations + +from typing import Any, Callable, TypeVar, cast + +T = TypeVar("T") + + +class Lazy: + """A value built on demand rather than when the test data is defined.""" + + def __init__(self, build: Callable[[], Any]) -> None: + self.build = build + + def resolve(self) -> Any: + return materialize(self.build()) + + def __repr__(self) -> str: + return "Lazy(...)" + + +def lazy(build: Callable[[], T]) -> T: + """Defer building a value until test time, keeping the field's normal type. + + Returns a ``Lazy`` at runtime but is typed as the value ``build`` produces, so + a field annotated ``list[dict]`` can hold ``lazy(lambda: [...])`` without + widening its type and ordinary consumers still see the built type. + ``materialize`` replaces it with the built value where the framework uses it. + """ + return cast(T, Lazy(build)) + + +def materialize(value: Any) -> Any: + """Return ``value`` with any ``Lazy`` in it replaced by its built result. + + Recurses through dicts, lists, and tuples so a ``Lazy`` nested inside a + command or an expected document is resolved too. When a container holds no + ``Lazy``, the original object is returned unchanged, so calling this on + ordinary values on a hot path is cheap and allocation-free. + """ + if isinstance(value, Lazy): + return value.resolve() + if isinstance(value, dict): + built_dict = {key: materialize(item) for key, item in value.items()} + if any(built_dict[key] is not value[key] for key in value): + return built_dict + return value + if isinstance(value, list): + built_list = [materialize(item) for item in value] + if any(new is not old for new, old in zip(built_list, value)): + return built_list + return value + if isinstance(value, tuple): + built_tuple = tuple(materialize(item) for item in value) + if any(new is not old for new, old in zip(built_tuple, value)): + return built_tuple + return value + return value diff --git a/documentdb_tests/framework/target_collection.py b/documentdb_tests/framework/target_collection.py index 82b9cbf4d..2870d45c6 100644 --- a/documentdb_tests/framework/target_collection.py +++ b/documentdb_tests/framework/target_collection.py @@ -15,6 +15,8 @@ from pymongo.database import Database from pymongo.operations import IndexModel +from documentdb_tests.framework.lazy_payload import materialize + @dataclass(frozen=True) class TargetCollection: @@ -406,6 +408,6 @@ def create(self, db: Database, collection: Collection) -> None: if self.indexes: db[name].create_indexes(self.indexes) if self.docs: - db[name].insert_many(self.docs) + db[name].insert_many(materialize(self.docs)) if self.indexes: db[name].create_indexes(self.indexes) From 2590b2dd6d9fa43a8d2f08ad58811678d02cd552 Mon Sep 17 00:00:00 2001 From: "Alina (Xi) Li" <96995091+alinaliBQ@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:19:28 -0700 Subject: [PATCH 16/35] Add `setParameter` tests (#650) Signed-off-by: Alina (Xi) Li Signed-off-by: RyanGarfinkel <113050972+RyanGarfinkel@users.noreply.github.com> --- .../commands/setParameter/__init__.py | 0 .../commands/setParameter/conftest.py | 31 ++ .../test_setParameter_argument_validation.py | 56 ++++ .../test_setParameter_bson_type_validation.py | 83 ++++++ .../test_setParameter_core_behavior.py | 141 +++++++++ .../setParameter/test_setParameter_errors.py | 268 ++++++++++++++++++ .../test_setParameter_hierarchical_params.py | 148 ++++++++++ .../setParameter/test_smoke_setParameter.py | 5 - 8 files changed, 727 insertions(+), 5 deletions(-) create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/setParameter/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/setParameter/conftest.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/setParameter/test_setParameter_argument_validation.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/setParameter/test_setParameter_bson_type_validation.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/setParameter/test_setParameter_core_behavior.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/setParameter/test_setParameter_errors.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/setParameter/test_setParameter_hierarchical_params.py diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/setParameter/__init__.py b/documentdb_tests/compatibility/tests/system/administration/commands/setParameter/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/setParameter/conftest.py b/documentdb_tests/compatibility/tests/system/administration/commands/setParameter/conftest.py new file mode 100644 index 000000000..c87774256 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/setParameter/conftest.py @@ -0,0 +1,31 @@ +"""Shared fixtures for setParameter tests. + +Provides an autouse fixture that snapshots mutable server parameters before +each test and unconditionally restores them after, preventing leaked state +even when a test fails. +""" + +import pytest + +from documentdb_tests.framework.executor import execute_admin_command + + +@pytest.fixture(autouse=True) +def restore_server_params(collection): + """Snapshot mutable server parameters before the test, restore after. + + This runs unconditionally (autouse) so that any test modifying server state + cannot leak changes into subsequent tests — whether it passes, fails, or errors. + """ + # Snapshot current values + snapshot = {} + for param in ["logLevel", "quiet", "automationServiceDescriptor", "logComponentVerbosity"]: + result = execute_admin_command(collection, {"getParameter": 1, param: 1}) + if isinstance(result, dict) and param in result: + snapshot[param] = result[param] + + yield + + # Restore original values unconditionally + for param, value in snapshot.items(): + execute_admin_command(collection, {"setParameter": 1, param: value}) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/setParameter/test_setParameter_argument_validation.py b/documentdb_tests/compatibility/tests/system/administration/commands/setParameter/test_setParameter_argument_validation.py new file mode 100644 index 000000000..387c5ef7b --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/setParameter/test_setParameter_argument_validation.py @@ -0,0 +1,56 @@ +"""Tests for setParameter argument validation (success cases). + +Validates control field Int64 max, parameter value range, and string param acceptance. +Type coercion matrices are in test_setParameter_bson_type_validation.py. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertSuccessPartial +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import INT64_MAX + +pytestmark = [pytest.mark.admin, pytest.mark.no_parallel] + + +# Property [Argument Validation]: setParameter accepts valid argument variations. +ARGUMENT_VALIDATION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "control_field_int64_max", + command=lambda ctx: {"setParameter": INT64_MAX, "logLevel": 0}, + expected={"ok": 1.0}, + msg="setParameter should accept Int64 max as control field value", + ), + CommandTestCase( + "fractional_double_coerces", + command=lambda ctx: {"setParameter": 1, "logLevel": 1.5}, + expected={"ok": 1.0}, + msg="setParameter should truncate fractional double for integer param", + ), + CommandTestCase( + "integer_valid_range", + command=lambda ctx: {"setParameter": 1, "logLevel": 5}, + expected={"ok": 1.0}, + msg="setParameter should accept logLevel at upper bound", + ), + CommandTestCase( + "string_param_valid", + command=lambda ctx: {"setParameter": 1, "automationServiceDescriptor": "test"}, + expected={"ok": 1.0}, + msg="setParameter should accept valid string value", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(ARGUMENT_VALIDATION_TESTS)) +def test_setParameter_argument_validation(database_client, collection, test): + """Test setParameter argument validation cases.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_admin_command(collection, test.build_command(ctx)) + assertSuccessPartial(result, test.build_expected(ctx), msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/setParameter/test_setParameter_bson_type_validation.py b/documentdb_tests/compatibility/tests/system/administration/commands/setParameter/test_setParameter_bson_type_validation.py new file mode 100644 index 000000000..bc083705d --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/setParameter/test_setParameter_bson_type_validation.py @@ -0,0 +1,83 @@ +"""Tests for setParameter BSON type validation (success cases). + +Validates control field acceptance of all BSON types, and type coercion +behavior for boolean and integer parameter values. +""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.framework.assertions import assertSuccessPartial +from documentdb_tests.framework.bson_type_validator import ( + BsonType, + BsonTypeTestCase, + generate_bson_acceptance_test_cases, +) +from documentdb_tests.framework.executor import execute_admin_command + +pytestmark = [pytest.mark.admin, pytest.mark.no_parallel] + + +# Property [Control Field Acceptance]: setParameter control field accepts all BSON types. +CONTROL_FIELD_PARAM = [ + BsonTypeTestCase( + id="setParameter_control", + msg="setParameter control field should accept all BSON types", + keyword="setParameter", + valid_types=list(BsonType), + requires={"logLevel": 0}, + ), +] + +CONTROL_FIELD_ACCEPTANCE = generate_bson_acceptance_test_cases(CONTROL_FIELD_PARAM) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", CONTROL_FIELD_ACCEPTANCE) +def test_setParameter_control_field_bson_type_accepted(collection, bson_type, sample_value, spec): + """Test setParameter control field accepts all BSON types.""" + result = execute_admin_command(collection, {"setParameter": sample_value, "logLevel": 0}) + assertSuccessPartial(result, {"ok": 1.0}, msg=f"{spec.msg} (bson_type={bson_type.value})") + + +# Property [Boolean Coercion]: boolean-typed params accept many BSON types via coercion. +@pytest.mark.parametrize( + "value,desc", + [ + pytest.param(True, "bool True", id="true"), + pytest.param(False, "bool False", id="false"), + pytest.param(1, "int 1", id="int1"), + pytest.param(0, "int 0", id="int0"), + pytest.param(1.0, "double 1.0", id="double1"), + pytest.param(0.0, "double 0.0", id="double0"), + pytest.param(Int64(1), "Int64(1)", id="long1"), + pytest.param(Int64(0), "Int64(0)", id="long0"), + pytest.param("true", "string 'true'", id="string"), + pytest.param([True], "array [True]", id="array"), + pytest.param({"a": True}, "document", id="document"), + ], +) +def test_setParameter_boolean_coercion_accepted(collection, value, desc): + """Test setParameter boolean parameter coercion.""" + result = execute_admin_command(collection, {"setParameter": 1, "quiet": value}) + assertSuccessPartial( + result, {"ok": 1.0}, msg=f"setParameter boolean param should accept {desc}" + ) + + +# Property [Integer Coercion Accepted]: integer-typed params accept whole-number numerics. +@pytest.mark.parametrize( + "value,desc", + [ + pytest.param(1, "int32", id="int32"), + pytest.param(Int64(1), "Int64", id="long"), + pytest.param(1.0, "whole double", id="whole_double"), + pytest.param(Decimal128("1"), "Decimal128 whole", id="decimal128_whole"), + pytest.param(True, "bool True", id="bool"), + ], +) +def test_setParameter_integer_coercion_accepted(collection, value, desc): + """Test setParameter integer parameter coercion.""" + result = execute_admin_command(collection, {"setParameter": 1, "logLevel": value}) + assertSuccessPartial( + result, {"ok": 1.0}, msg=f"setParameter integer param should accept {desc}" + ) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/setParameter/test_setParameter_core_behavior.py b/documentdb_tests/compatibility/tests/system/administration/commands/setParameter/test_setParameter_core_behavior.py new file mode 100644 index 000000000..c1afadb76 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/setParameter/test_setParameter_core_behavior.py @@ -0,0 +1,141 @@ +"""Tests for setParameter command core behavior. + +Validates single/multiple parameter modification, admin database requirement, +runtime vs startup-only parameters, command shape, response structure, and +getParameter interaction. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertSuccessPartial +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = [pytest.mark.admin, pytest.mark.no_parallel] + + +# Property [Command Acceptance]: setParameter accepts valid commands on admin db. +COMMAND_ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "admin_db_succeeds", + command=lambda ctx: {"setParameter": 1, "logLevel": 0}, + expected={"ok": 1.0}, + msg="setParameter should succeed on admin db", + ), + CommandTestCase( + "comment_field_accepted", + command=lambda ctx: {"setParameter": 1, "logLevel": 0, "comment": "test"}, + expected={"ok": 1.0}, + msg="setParameter should accept comment field alongside params", + ), + CommandTestCase( + "single_param_returns_ok", + command=lambda ctx: {"setParameter": 1, "logLevel": 1}, + expected={"ok": 1.0}, + msg="setParameter should return ok:1", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(COMMAND_ACCEPTANCE_TESTS)) +def test_setParameter_accepted(database_client, collection, test): + """Test setParameter command acceptance cases.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_admin_command(collection, test.build_command(ctx)) + assertSuccessPartial(result, test.build_expected(ctx), msg=test.msg) + + +# Property [Response Structure]: setParameter returns ok:1 and previous value in 'was'. +def test_setParameter_returns_was_field(collection): + """Test setParameter response includes the previous value in 'was' field.""" + execute_admin_command(collection, {"setParameter": 1, "logLevel": 0}) + result = execute_admin_command(collection, {"setParameter": 1, "logLevel": 2}) + assertSuccessPartial( + result, {"ok": 1.0, "was": 0}, msg="setParameter should report previous value in 'was'" + ) + + +def test_setParameter_boolean_was_type(collection): + """Test setParameter 'was' field for a boolean parameter is boolean type.""" + execute_admin_command(collection, {"setParameter": 1, "quiet": False}) + result = execute_admin_command(collection, {"setParameter": 1, "quiet": True}) + assertSuccessPartial( + result, {"ok": 1.0, "was": False}, msg="setParameter 'was' should be boolean for quiet" + ) + + +# Property [Value Persistence]: setParameter changes are reflected in getParameter. +def test_setParameter_getParameter_reflects_new_value(collection): + """Test setParameter changes are reflected via getParameter.""" + execute_admin_command(collection, {"setParameter": 1, "logLevel": 0}) + execute_admin_command(collection, {"setParameter": 1, "logLevel": 3}) + result = execute_admin_command(collection, {"getParameter": 1, "logLevel": 1}) + assertSuccessPartial( + result, {"logLevel": 3}, msg="setParameter should reflect new value via getParameter" + ) + + +def test_setParameter_getParameter_unchanged_after_failure(collection): + """Test setParameter getParameter is unchanged after a failed set.""" + execute_admin_command(collection, {"setParameter": 1, "logLevel": 0}) + execute_admin_command(collection, {"setParameter": 1, "logLevel": "invalid"}) + result = execute_admin_command(collection, {"getParameter": 1, "logLevel": 1}) + assertSuccessPartial( + result, {"logLevel": 0}, msg="setParameter should leave value unchanged after failure" + ) + + +# Property [Idempotency]: setting a parameter to its current value succeeds. +def test_setParameter_idempotent_set(collection): + """Test setParameter setting a parameter to its current value returns ok:1.""" + execute_admin_command(collection, {"setParameter": 1, "logLevel": 0}) + result = execute_admin_command(collection, {"setParameter": 1, "logLevel": 0}) + assertSuccessPartial( + result, {"ok": 1.0, "was": 0}, msg="setParameter should succeed idempotently" + ) + + +# Property [Multiple Parameters]: setParameter sets multiple params in one command. +def test_setParameter_two_params_returns_ok(collection): + """Test setParameter with two params in one command sets both.""" + execute_admin_command(collection, {"setParameter": 1, "logLevel": 0, "quiet": False}) + result = execute_admin_command(collection, {"setParameter": 1, "logLevel": 1, "quiet": True}) + assertSuccessPartial(result, {"ok": 1.0}, msg="setParameter should set both parameters") + + +# Property [Round Trip]: value can be restored via 'was' field. +def test_setParameter_round_trip_restore(collection): + """Test setParameter round-trip: set new, restore via 'was' field.""" + execute_admin_command(collection, {"setParameter": 1, "logLevel": 0}) + set_result = execute_admin_command(collection, {"setParameter": 1, "logLevel": 3}) + original_val = set_result["was"] + execute_admin_command(collection, {"setParameter": 1, "logLevel": original_val}) + result = execute_admin_command(collection, {"getParameter": 1, "logLevel": 1}) + assertSuccessPartial(result, {"logLevel": 0}, msg="setParameter should restore to original") + + +# Property [Boolean Toggle]: boolean-typed parameters toggle correctly. +def test_setParameter_boolean_toggle(collection): + """Test setParameter boolean-typed parameter toggles correctly.""" + execute_admin_command(collection, {"setParameter": 1, "quiet": False}) + result = execute_admin_command(collection, {"getParameter": 1, "quiet": 1}) + assertSuccessPartial(result, {"quiet": False}, msg="setParameter boolean should be False") + + +# Property [Ordering Independence]: parameter order in command does not affect result. +def test_setParameter_ordering_independence(collection): + """Test setParameter parameter order in command does not affect result.""" + execute_admin_command(collection, {"setParameter": 1, "logLevel": 0, "quiet": False}) + execute_admin_command(collection, {"setParameter": 1, "logLevel": 2, "quiet": True}) + state1 = execute_admin_command(collection, {"getParameter": 1, "logLevel": 1}) + execute_admin_command(collection, {"setParameter": 1, "logLevel": 0, "quiet": False}) + execute_admin_command(collection, {"setParameter": 1, "quiet": True, "logLevel": 2}) + state2 = execute_admin_command(collection, {"getParameter": 1, "logLevel": 1}) + assertSuccessPartial( + state2, {"logLevel": state1["logLevel"]}, msg="setParameter order should not matter" + ) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/setParameter/test_setParameter_errors.py b/documentdb_tests/compatibility/tests/system/administration/commands/setParameter/test_setParameter_errors.py new file mode 100644 index 000000000..224cd990d --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/setParameter/test_setParameter_errors.py @@ -0,0 +1,268 @@ +"""Tests for setParameter error cases. + +ALL error assertions for setParameter are consolidated in this file. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import ( + assertResult, + assertSuccessPartial, +) +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + INVALID_OPTIONS_ERROR, + OVERFLOW_ERROR, + TYPE_MISMATCH_ERROR, + UNAUTHORIZED_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import INT32_MAX, INT32_MIN + +pytestmark = [pytest.mark.admin, pytest.mark.no_parallel] + + +# Property [Error Codes]: setParameter returns correct error codes for invalid inputs. +ERROR_CODE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "nonexistent_param", + command=lambda ctx: {"setParameter": 1, "nonExistentXYZ": 1}, + error_code=INVALID_OPTIONS_ERROR, + msg="setParameter should reject non-existent param", + ), + CommandTestCase( + "startup_only_param", + command=lambda ctx: {"setParameter": 1, "port": 27018}, + error_code=INVALID_OPTIONS_ERROR, + msg="setParameter should reject startup-only param", + ), + CommandTestCase( + "out_of_range_below_min", + command=lambda ctx: {"setParameter": 1, "logLevel": -1}, + error_code=BAD_VALUE_ERROR, + msg="setParameter should reject value below minimum", + ), + CommandTestCase( + "above_int32_max", + command=lambda ctx: {"setParameter": 1, "logLevel": INT32_MAX + 1}, + error_code=BAD_VALUE_ERROR, + msg="setParameter should reject value exceeding int32 max", + ), + CommandTestCase( + "empty_param_name", + command=lambda ctx: {"setParameter": 1, "": 1}, + error_code=INVALID_OPTIONS_ERROR, + msg="setParameter should reject empty param name", + ), + CommandTestCase( + "no_param_pair", + command=lambda ctx: {"setParameter": 1}, + error_code=INVALID_OPTIONS_ERROR, + msg="setParameter should reject missing param pair", + ), + CommandTestCase( + "multi_param_with_invalid", + command=lambda ctx: {"setParameter": 1, "logLevel": 0, "nonExistentXYZ": 1}, + error_code=INVALID_OPTIONS_ERROR, + msg="setParameter should reject multi-param command when one is invalid", + ), +] + +# Property [Name Rejection]: setParameter rejects invalid parameter names. +NAME_REJECTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "case_sensitive", + command=lambda ctx: {"setParameter": 1, "LogLevel": 0}, + error_code=INVALID_OPTIONS_ERROR, + msg="setParameter should reject wrong-case param name", + ), + CommandTestCase( + "long_name", + command=lambda ctx: {"setParameter": 1, "a" * 1000: 1}, + error_code=INVALID_OPTIONS_ERROR, + msg="setParameter should reject very long param name", + ), + CommandTestCase( + "dotted_name", + command=lambda ctx: {"setParameter": 1, "log.level": 1}, + error_code=INVALID_OPTIONS_ERROR, + msg="setParameter should reject dotted param name", + ), + CommandTestCase( + "dollar_name", + command=lambda ctx: {"setParameter": 1, "$logLevel": 1}, + error_code=INVALID_OPTIONS_ERROR, + msg="setParameter should reject dollar-prefixed param name", + ), + CommandTestCase( + "whitespace_name", + command=lambda ctx: {"setParameter": 1, " logLevel ": 0}, + error_code=INVALID_OPTIONS_ERROR, + msg="setParameter should reject whitespace in param name", + ), + CommandTestCase( + "numeric_string_name", + command=lambda ctx: {"setParameter": 1, "12345": 0}, + error_code=INVALID_OPTIONS_ERROR, + msg="setParameter should reject numeric string param name", + ), +] + +# Property [Value Type Rejection]: setParameter rejects invalid value types. +VALUE_TYPE_REJECTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "string_param_with_numeric", + command=lambda ctx: {"setParameter": 1, "automationServiceDescriptor": 12345}, + error_code=TYPE_MISMATCH_ERROR, + msg="setParameter should reject numeric value for string param", + ), + CommandTestCase( + "string_param_overlength", + command=lambda ctx: {"setParameter": 1, "automationServiceDescriptor": "x" * 65}, + error_code=OVERFLOW_ERROR, + msg="setParameter should reject over-length string value", + ), +] + +# Property [Hierarchical Type Rejection]: logComponentVerbosity rejects invalid types. +HIERARCHICAL_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "hierarchical_string_value", + command=lambda ctx: {"setParameter": 1, "logComponentVerbosity": "abc"}, + error_code=TYPE_MISMATCH_ERROR, + msg="setParameter should reject string for logComponentVerbosity", + ), + CommandTestCase( + "hierarchical_nested_string_verbosity", + command=lambda ctx: { + "setParameter": 1, + "logComponentVerbosity": {"command": {"verbosity": "abc"}}, + }, + error_code=BAD_VALUE_ERROR, + msg="setParameter should reject string for nested verbosity", + ), + CommandTestCase( + "hierarchical_nested_overflow", + command=lambda ctx: { + "setParameter": 1, + "logComponentVerbosity": {"command": {"verbosity": INT32_MAX + 1}}, + }, + error_code=BAD_VALUE_ERROR, + msg="setParameter should reject overflow for nested verbosity", + ), + CommandTestCase( + "hierarchical_nested_underflow", + command=lambda ctx: { + "setParameter": 1, + "logComponentVerbosity": {"command": {"verbosity": INT32_MIN - 1}}, + }, + error_code=BAD_VALUE_ERROR, + msg="setParameter should reject underflow for nested verbosity", + ), + CommandTestCase( + "hierarchical_unknown_component", + command=lambda ctx: { + "setParameter": 1, + "logComponentVerbosity": {"unknownComponent": {"verbosity": 1}}, + }, + error_code=BAD_VALUE_ERROR, + msg="setParameter should reject unknown component name", + ), + CommandTestCase( + "hierarchical_nested_nan_verbosity", + command=lambda ctx: { + "setParameter": 1, + "logComponentVerbosity": {"command": {"verbosity": float("nan")}}, + }, + error_code=BAD_VALUE_ERROR, + msg="setParameter should reject NaN for nested verbosity", + ), +] + +# Property [Integer Coercion Rejected]: integer-typed params reject non-numeric types. +INTEGER_COERCION_REJECTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "integer_rejects_string", + command=lambda ctx: {"setParameter": 1, "logLevel": "1"}, + error_code=BAD_VALUE_ERROR, + msg="setParameter should reject string for integer param", + ), + CommandTestCase( + "integer_rejects_array", + command=lambda ctx: {"setParameter": 1, "logLevel": [1]}, + error_code=BAD_VALUE_ERROR, + msg="setParameter should reject array for integer param", + ), + CommandTestCase( + "integer_rejects_document", + command=lambda ctx: {"setParameter": 1, "logLevel": {"a": 1}}, + error_code=BAD_VALUE_ERROR, + msg="setParameter should reject document for integer param", + ), +] + +ALL_ERROR_TESTS = ( + ERROR_CODE_TESTS + + NAME_REJECTION_TESTS + + VALUE_TYPE_REJECTION_TESTS + + HIERARCHICAL_ERROR_TESTS + + INTEGER_COERCION_REJECTION_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_ERROR_TESTS)) +def test_setParameter_errors(database_client, collection, test): + """Test setParameter error cases.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_admin_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + + +# Property [Non-Admin Rejection]: setParameter fails on non-admin database. +NON_ADMIN_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "non_admin_db", + command=lambda ctx: {"setParameter": 1, "logLevel": 0}, + error_code=UNAUTHORIZED_ERROR, + msg="setParameter should reject non-admin database", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(NON_ADMIN_TESTS)) +def test_setParameter_non_admin_db(database_client, collection, test): + """Test setParameter fails on non-admin database.""" + 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, + ) + + +# Property [Atomicity]: multi-parameter set is atomic on failure. +# Standalone because it requires multi-step state verification. +def test_setParameter_multi_param_atomic_on_failure(collection): + """Test multi-parameter command with invalid second param does not apply first.""" + execute_admin_command(collection, {"setParameter": 1, "logLevel": 0}) + execute_admin_command(collection, {"setParameter": 1, "logLevel": 3, "nonExistentXYZ": 1}) + result = execute_admin_command(collection, {"getParameter": 1, "logLevel": 1}) + assertSuccessPartial( + result, {"logLevel": 0}, msg="First param should not be applied when second fails" + ) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/setParameter/test_setParameter_hierarchical_params.py b/documentdb_tests/compatibility/tests/system/administration/commands/setParameter/test_setParameter_hierarchical_params.py new file mode 100644 index 000000000..edc3b7629 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/setParameter/test_setParameter_hierarchical_params.py @@ -0,0 +1,148 @@ +"""Tests for setParameter hierarchical/object parameter handling (success cases). + +Validates logComponentVerbosity nested parameter behavior including +read defaults, multi-member set, atomic rejection, and bare numeric form. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertSuccessPartial +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = [pytest.mark.admin, pytest.mark.no_parallel] + + +# Property [Single-Command Hierarchical]: simple set/read operations. +HIERARCHICAL_SINGLE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "multi_member_set", + command=lambda ctx: { + "setParameter": 1, + "logComponentVerbosity": {"command": {"verbosity": 2}, "network": {"verbosity": 3}}, + }, + expected={"ok": 1.0}, + msg="setParameter multi-member set should succeed", + ), + CommandTestCase( + "bare_numeric_form", + command=lambda ctx: {"setParameter": 1, "logComponentVerbosity": {"command": 4}}, + expected={"ok": 1.0}, + msg="setParameter should accept bare numeric form", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(HIERARCHICAL_SINGLE_TESTS)) +def test_setParameter_hierarchical_single(database_client, collection, test): + """Test setParameter hierarchical single-command cases.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_admin_command(collection, test.build_command(ctx)) + assertSuccessPartial(result, test.build_expected(ctx), msg=test.msg) + + +# Property [Readability]: logComponentVerbosity is readable with defined defaults. +def test_setParameter_hierarchical_param_readable(collection): + """Test setParameter reading logComponentVerbosity returns a defined value.""" + result = execute_admin_command(collection, {"getParameter": 1, "logComponentVerbosity": 1}) + assertSuccessPartial( + result, {"ok": 1.0}, msg="setParameter should be able to read hierarchical param" + ) + + +def test_setParameter_hierarchical_nested_field_defined(collection): + """Test setParameter deeply nested verbosity field is defined.""" + result = execute_admin_command(collection, {"getParameter": 1, "logComponentVerbosity": 1}) + assertSuccessPartial( + result, + {"logComponentVerbosity": {"network": {"asio": {"verbosity": -1}}}}, + msg="setParameter nested component should have defined verbosity", + ) + + +# Property [Top-Level Verbosity]: top-level verbosity matches scalar logLevel. +def test_setParameter_hierarchical_top_level_verbosity(collection): + """Test setParameter top-level verbosity matches scalar logLevel default.""" + execute_admin_command(collection, {"setParameter": 1, "logLevel": 0}) + result = execute_admin_command(collection, {"getParameter": 1, "logComponentVerbosity": 1}) + assertSuccessPartial( + result, + {"logComponentVerbosity": {"verbosity": 0}}, + msg="setParameter top-level verbosity should match logLevel", + ) + + +# Property [Multi-Member Readback]: reading back reflects each member set. +def test_setParameter_hierarchical_multi_member_readback(collection): + """Test setParameter reading back reflects each member set in a multi-member command.""" + execute_admin_command( + collection, + { + "setParameter": 1, + "logComponentVerbosity": {"command": {"verbosity": 2}, "network": {"verbosity": 3}}, + }, + ) + read_result = execute_admin_command(collection, {"getParameter": 1, "logComponentVerbosity": 1}) + assertSuccessPartial( + read_result, + {"logComponentVerbosity": {"command": {"verbosity": 2}, "network": {"verbosity": 3}}}, + msg="setParameter should reflect both members after set", + ) + + +# Property [Atomic Rejection]: no members change when a multi-member set is rejected. +def test_setParameter_hierarchical_atomic_rejection(collection): + """Test setParameter no members are changed when a multi-member set is rejected.""" + execute_admin_command( + collection, + {"setParameter": 1, "logComponentVerbosity": {"command": {"verbosity": -1}}}, + ) + execute_admin_command( + collection, + { + "setParameter": 1, + "logComponentVerbosity": {"command": {"verbosity": 2}, "unknownXYZ": {"verbosity": 1}}, + }, + ) + result = execute_admin_command(collection, {"getParameter": 1, "logComponentVerbosity": 1}) + assertSuccessPartial( + result, + {"logComponentVerbosity": {"command": {"verbosity": -1}}}, + msg="setParameter should not change any member on atomic rejection", + ) + + +# Property [Clear With Negative]: setting -1 resets to inherited default. +def test_setParameter_hierarchical_clear_with_negative(collection): + """Test setParameter clearing nested members with -1 resets to inherited default.""" + execute_admin_command( + collection, + {"setParameter": 1, "logComponentVerbosity": {"command": {"verbosity": 3}}}, + ) + execute_admin_command( + collection, + {"setParameter": 1, "logComponentVerbosity": {"command": {"verbosity": -1}}}, + ) + result = execute_admin_command(collection, {"getParameter": 1, "logComponentVerbosity": 1}) + assertSuccessPartial( + result, + {"logComponentVerbosity": {"command": {"verbosity": -1}}}, + msg="setParameter should reset member to -1 (inherited) after clear", + ) + + +# Property [Bare Numeric Readback]: bare numeric form takes effect when read back. +def test_setParameter_hierarchical_bare_numeric_readback(collection): + """Test setParameter bare numeric component set takes effect when read back.""" + execute_admin_command(collection, {"setParameter": 1, "logComponentVerbosity": {"command": 4}}) + read_result = execute_admin_command(collection, {"getParameter": 1, "logComponentVerbosity": 1}) + assertSuccessPartial( + read_result, + {"logComponentVerbosity": {"command": {"verbosity": 4}}}, + msg="setParameter bare numeric should set verbosity", + ) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/setParameter/test_smoke_setParameter.py b/documentdb_tests/compatibility/tests/system/administration/commands/setParameter/test_smoke_setParameter.py index 5b60edc76..b91245425 100644 --- a/documentdb_tests/compatibility/tests/system/administration/commands/setParameter/test_smoke_setParameter.py +++ b/documentdb_tests/compatibility/tests/system/administration/commands/setParameter/test_smoke_setParameter.py @@ -14,12 +14,7 @@ def test_smoke_setParameter(collection): """Test basic setParameter behavior.""" - get_result = execute_admin_command(collection, {"getParameter": 1, "logLevel": 1}) - original_value = get_result.get("logLevel", 0) if not isinstance(get_result, Exception) else 0 - result = execute_admin_command(collection, {"setParameter": 1, "logLevel": 0}) expected = {"ok": 1.0} assertSuccessPartial(result, expected, msg="Should support setParameter command") - - execute_admin_command(collection, {"setParameter": 1, "logLevel": original_value}) From 84e4efc4508090955095cc14e3f178ee2ef0c20c Mon Sep 17 00:00:00 2001 From: Victor Tsang Date: Mon, 13 Jul 2026 11:10:36 -0700 Subject: [PATCH 17/35] Add admin command tests for setDefaultRWConcern (#648) Signed-off-by: Victor [C] Tsang Signed-off-by: RyanGarfinkel <113050972+RyanGarfinkel@users.noreply.github.com> --- .../commands/setDefaultRWConcern/__init__.py | 0 ...etDefaultRWConcern_bson_type_validation.py | 87 ++++++ .../test_setDefaultRWConcern_core_behavior.py | 154 +++++++++ .../test_setDefaultRWConcern_errors.py | 291 ++++++++++++++++++ ..._setDefaultRWConcern_response_structure.py | 75 +++++ .../test_setDefaultRWConcern_valid_inputs.py | 143 +++++++++ .../administration/utils/admin_test_case.py | 7 +- 7 files changed, 753 insertions(+), 4 deletions(-) create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/setDefaultRWConcern/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/setDefaultRWConcern/test_setDefaultRWConcern_bson_type_validation.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/setDefaultRWConcern/test_setDefaultRWConcern_core_behavior.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/setDefaultRWConcern/test_setDefaultRWConcern_errors.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/setDefaultRWConcern/test_setDefaultRWConcern_response_structure.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/setDefaultRWConcern/test_setDefaultRWConcern_valid_inputs.py diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/setDefaultRWConcern/__init__.py b/documentdb_tests/compatibility/tests/system/administration/commands/setDefaultRWConcern/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/setDefaultRWConcern/test_setDefaultRWConcern_bson_type_validation.py b/documentdb_tests/compatibility/tests/system/administration/commands/setDefaultRWConcern/test_setDefaultRWConcern_bson_type_validation.py new file mode 100644 index 000000000..2c00c3fe5 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/setDefaultRWConcern/test_setDefaultRWConcern_bson_type_validation.py @@ -0,0 +1,87 @@ +"""BSON type validation tests for setDefaultRWConcern.""" + +import pytest + +from documentdb_tests.framework.assertions import assertFailureCode, assertProperties +from documentdb_tests.framework.bson_type_validator import ( + BsonType, + BsonTypeTestCase, + generate_bson_acceptance_test_cases, + generate_bson_rejection_test_cases, +) +from documentdb_tests.framework.error_codes import TYPE_MISMATCH_ERROR +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.property_checks import Eq + +pytestmark = [pytest.mark.admin, pytest.mark.no_parallel, pytest.mark.requires(cluster_admin=True)] + + +# NULL is skipped from rejection: a null concern is treated as omitted, not rejected. +# The null-as-omitted behavior is covered in test_setDefaultRWConcern_valid_inputs.py. +FIELD_SPECS: list[BsonTypeTestCase] = [ + BsonTypeTestCase( + id="setDefaultRWConcern_value", + msg="setDefaultRWConcern command value is type-agnostic (any BSON type accepted)", + keyword="setDefaultRWConcern", + valid_types=list(BsonType), + requires={"defaultReadConcern": {"level": "local"}}, + ), + BsonTypeTestCase( + id="defaultReadConcern", + msg="defaultReadConcern should accept object types only", + keyword="defaultReadConcern", + valid_types=[BsonType.OBJECT], + skip_rejection_types=[BsonType.NULL], + valid_inputs={BsonType.OBJECT: {"level": "local"}}, + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="defaultWriteConcern", + msg="defaultWriteConcern should accept object types only", + keyword="defaultWriteConcern", + valid_types=[BsonType.OBJECT], + skip_rejection_types=[BsonType.NULL], + valid_inputs={BsonType.OBJECT: {"w": 1}}, + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="writeConcern", + msg="writeConcern should accept object types only", + keyword="writeConcern", + valid_types=[BsonType.OBJECT], + skip_rejection_types=[BsonType.NULL], + valid_inputs={BsonType.OBJECT: {"w": 1}}, + requires={"defaultReadConcern": {"level": "local"}}, + default_error_code=TYPE_MISMATCH_ERROR, + ), +] + +ACCEPTANCE_CASES = generate_bson_acceptance_test_cases(FIELD_SPECS) +REJECTION_CASES = generate_bson_rejection_test_cases(FIELD_SPECS) + + +def _build_command(spec: BsonTypeTestCase, sample_value): + """Build a setDefaultRWConcern command with ``sample_value`` in ``spec.keyword``.""" + keyword = spec.keyword + assert keyword is not None, "BsonTypeTestCase must define a keyword" + if keyword == "setDefaultRWConcern": + command = {"setDefaultRWConcern": sample_value} + else: + command = {"setDefaultRWConcern": 1, keyword: sample_value} + if spec.requires: + command.update(spec.requires) + return command + + +@pytest.mark.parametrize("bson_type,sample_value,spec", ACCEPTANCE_CASES) +def test_setDefaultRWConcern_bson_type_accepted(collection, bson_type, sample_value, spec): + """Test each field accepts the BSON types declared valid for it.""" + result = execute_admin_command(collection, _build_command(spec, sample_value)) + assertProperties(result, {"ok": Eq(1.0)}, msg=spec.msg, raw_res=True) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", REJECTION_CASES) +def test_setDefaultRWConcern_bson_type_rejected(collection, bson_type, sample_value, spec): + """Test each field rejects BSON types outside its declared valid set.""" + result = execute_admin_command(collection, _build_command(spec, sample_value)) + assertFailureCode(result, spec.expected_code(bson_type), msg=spec.msg) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/setDefaultRWConcern/test_setDefaultRWConcern_core_behavior.py b/documentdb_tests/compatibility/tests/system/administration/commands/setDefaultRWConcern/test_setDefaultRWConcern_core_behavior.py new file mode 100644 index 000000000..56f5e470a --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/setDefaultRWConcern/test_setDefaultRWConcern_core_behavior.py @@ -0,0 +1,154 @@ +"""Core behavior tests for setDefaultRWConcern.""" + +import pytest + +from documentdb_tests.compatibility.tests.system.administration.utils.admin_test_case import ( + AdminTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, Gt + +pytestmark = [pytest.mark.admin, pytest.mark.no_parallel, pytest.mark.requires(cluster_admin=True)] + + +CORE_TESTS: list[AdminTestCase] = [ + AdminTestCase( + "set_both_defaults", + command={ + "setDefaultRWConcern": 1, + "defaultReadConcern": {"level": "majority"}, + "defaultWriteConcern": {"w": 1}, + }, + expected={"ok": Eq(1.0)}, + msg="Should succeed with both defaults", + ), + AdminTestCase( + "round_trip_write_concern", + setup_commands=({"setDefaultRWConcern": 1, "defaultWriteConcern": {"w": 1}},), + command={"getDefaultRWConcern": 1}, + expected={"ok": Eq(1.0), "defaultWriteConcern": {"w": Eq(1), "wtimeout": Eq(0)}}, + msg="getDefaultRWConcern should reflect the configured write concern", + ), + AdminTestCase( + "round_trip_read_concern", + setup_commands=({"setDefaultRWConcern": 1, "defaultReadConcern": {"level": "majority"}},), + command={"getDefaultRWConcern": 1}, + expected={"ok": Eq(1.0), "defaultReadConcern": {"level": Eq("majority")}}, + msg="getDefaultRWConcern should reflect the configured read concern", + ), + AdminTestCase( + "idempotent_write_concern", + setup_commands=({"setDefaultRWConcern": 1, "defaultWriteConcern": {"w": 1}},), + command={"setDefaultRWConcern": 1, "defaultWriteConcern": {"w": 1}}, + expected={"ok": Eq(1.0), "defaultWriteConcern": {"w": Eq(1), "wtimeout": Eq(0)}}, + msg="Second application should succeed with same value", + ), + AdminTestCase( + "change_read_concern_level", + setup_commands=({"setDefaultRWConcern": 1, "defaultReadConcern": {"level": "local"}},), + command={"setDefaultRWConcern": 1, "defaultReadConcern": {"level": "majority"}}, + expected={"ok": Eq(1.0), "defaultReadConcern": {"level": Eq("majority")}}, + msg="Latest read concern level should win", + ), + AdminTestCase( + "change_write_concern_value", + setup_commands=({"setDefaultRWConcern": 1, "defaultWriteConcern": {"w": 1}},), + command={"setDefaultRWConcern": 1, "defaultWriteConcern": {"w": "majority"}}, + expected={"ok": Eq(1.0), "defaultWriteConcern": {"w": Eq("majority")}}, + msg="Should succeed changing write concern value", + ), + AdminTestCase( + "observable_via_get", + setup_commands=({"setDefaultRWConcern": 1, "defaultWriteConcern": {"w": 1}},), + command={"getDefaultRWConcern": 1}, + expected={"defaultWriteConcernSource": Eq("global")}, + msg="Source should be 'global' after explicit set", + ), + AdminTestCase( + "unset_read_concern_reverts_source_to_implicit", + setup_commands=( + {"setDefaultRWConcern": 1, "defaultReadConcern": {"level": "majority"}}, + {"setDefaultRWConcern": 1, "defaultReadConcern": {}}, + ), + command={"getDefaultRWConcern": 1}, + expected={"ok": Eq(1.0), "defaultReadConcernSource": Eq("implicit")}, + msg="unsetting the read concern with {} reverts its source from 'global' to 'implicit'", + ), + AdminTestCase( + "set_read_concern_preserves_existing_write_concern", + setup_commands=( + {"setDefaultRWConcern": 1, "defaultWriteConcern": {"w": 1}}, + {"setDefaultRWConcern": 1, "defaultReadConcern": {"level": "majority"}}, + ), + command={"getDefaultRWConcern": 1}, + expected={ + "defaultWriteConcern": {"w": Eq(1), "wtimeout": Eq(0)}, + "defaultReadConcern": {"level": Eq("majority")}, + }, + msg="setting only the read concern leaves a previously-set write concern intact", + ), + AdminTestCase( + "idempotent_read_concern", + setup_commands=({"setDefaultRWConcern": 1, "defaultReadConcern": {"level": "majority"}},), + command={"setDefaultRWConcern": 1, "defaultReadConcern": {"level": "majority"}}, + expected={"ok": Eq(1.0), "defaultReadConcern": {"level": Eq("majority")}}, + msg="re-applying the same read concern should succeed with the same value", + ), + AdminTestCase( + "round_trip_write_concern_majority", + setup_commands=({"setDefaultRWConcern": 1, "defaultWriteConcern": {"w": "majority"}},), + command={"getDefaultRWConcern": 1}, + expected={"ok": Eq(1.0), "defaultWriteConcern": {"w": Eq("majority"), "wtimeout": Eq(0)}}, + msg="getDefaultRWConcern should reflect a w:'majority' write concern", + ), + AdminTestCase( + "round_trip_read_concern_available", + setup_commands=({"setDefaultRWConcern": 1, "defaultReadConcern": {"level": "available"}},), + command={"getDefaultRWConcern": 1}, + expected={"ok": Eq(1.0), "defaultReadConcern": {"level": Eq("available")}}, + msg="getDefaultRWConcern should reflect an 'available' read concern", + ), + AdminTestCase( + "round_trip_both_defaults", + setup_commands=( + { + "setDefaultRWConcern": 1, + "defaultReadConcern": {"level": "majority"}, + "defaultWriteConcern": {"w": 1}, + }, + ), + command={"getDefaultRWConcern": 1}, + expected={ + "defaultReadConcern": {"level": Eq("majority")}, + "defaultWriteConcern": {"w": Eq(1), "wtimeout": Eq(0)}, + }, + msg="getDefaultRWConcern reflects both defaults set in a single call", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(CORE_TESTS)) +def test_setDefaultRWConcern_core(collection, test): + """Run a setDefaultRWConcern core behavior case.""" + for setup in test.setup_commands: + execute_admin_command(collection, setup) + result = execute_admin_command(collection, test.command) + assertResult(result, expected=test.expected, msg=test.msg, raw_res=True) + + +def test_setDefaultRWConcern_update_op_time_advances(collection): + """Test that updateOpTime advances after a value-changing set.""" + execute_admin_command(collection, {"setDefaultRWConcern": 1, "defaultWriteConcern": {"w": 1}}) + before = execute_admin_command(collection, {"getDefaultRWConcern": 1}) + execute_admin_command( + collection, {"setDefaultRWConcern": 1, "defaultWriteConcern": {"w": "majority"}} + ) + after = execute_admin_command(collection, {"getDefaultRWConcern": 1}) + assertResult( + after, + expected={"updateOpTime": Gt(before["updateOpTime"])}, + msg="updateOpTime should advance after a value-changing set", + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/setDefaultRWConcern/test_setDefaultRWConcern_errors.py b/documentdb_tests/compatibility/tests/system/administration/commands/setDefaultRWConcern/test_setDefaultRWConcern_errors.py new file mode 100644 index 000000000..65f4cadac --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/setDefaultRWConcern/test_setDefaultRWConcern_errors.py @@ -0,0 +1,291 @@ +"""Failure-case tests for setDefaultRWConcern: input rejections, atomic-failure +behavior, and rejection outside the admin database.""" + +import pytest + +from documentdb_tests.compatibility.tests.system.administration.utils.admin_test_case import ( + AdminTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + FAILED_TO_PARSE_ERROR, + ILLEGAL_OPERATION_ERROR, + TYPE_MISMATCH_ERROR, + UNAUTHORIZED_ERROR, + UNKNOWN_REPL_WRITE_CONCERN_ERROR, + UNRECOGNIZED_COMMAND_FIELD_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq + +pytestmark = [pytest.mark.admin, pytest.mark.no_parallel, pytest.mark.requires(cluster_admin=True)] + + +ERROR_TESTS: list[AdminTestCase] = [ + AdminTestCase( + "neither_default_specified", + command={"setDefaultRWConcern": 1}, + error_code=BAD_VALUE_ERROR, + msg="Should reject when neither default specified", + ), + AdminTestCase( + "unrecognized_field", + command={ + "setDefaultRWConcern": 1, + "defaultReadConcern": {"level": "local"}, + "unknownField": 1, + }, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="Should reject unrecognized fields", + ), + AdminTestCase( + "comment_does_not_mask_error", + command={"setDefaultRWConcern": 1, "comment": "a comment"}, + error_code=BAD_VALUE_ERROR, + msg="Comment should not mask at-least-one error", + ), + AdminTestCase( + "both_null_fails", + command={ + "setDefaultRWConcern": 1, + "defaultReadConcern": None, + "defaultWriteConcern": None, + }, + error_code=BAD_VALUE_ERROR, + msg="Both null should fail like both absent", + ), + AdminTestCase( + "command_write_concern_unknown_tag_rejected", + command={ + "setDefaultRWConcern": 1, + "defaultReadConcern": {"level": "local"}, + "writeConcern": {"w": "customTag"}, + }, + error_code=UNKNOWN_REPL_WRITE_CONCERN_ERROR, + msg="unknown tag in the command-level writeConcern should be rejected", + ), + AdminTestCase( + "read_level_linearizable_rejected", + command={"setDefaultRWConcern": 1, "defaultReadConcern": {"level": "linearizable"}}, + error_code=BAD_VALUE_ERROR, + msg="linearizable not supported as default", + ), + AdminTestCase( + "read_level_snapshot_rejected", + command={"setDefaultRWConcern": 1, "defaultReadConcern": {"level": "snapshot"}}, + error_code=BAD_VALUE_ERROR, + msg="snapshot not supported as default", + ), + AdminTestCase( + "unsupported_read_level_arbitrary", + command={"setDefaultRWConcern": 1, "defaultReadConcern": {"level": "foobar"}}, + error_code=BAD_VALUE_ERROR, + msg="Should reject arbitrary string level", + ), + AdminTestCase( + "write_concern_unknown_tag", + command={"setDefaultRWConcern": 1, "defaultWriteConcern": {"w": "customTag"}}, + error_code=UNKNOWN_REPL_WRITE_CONCERN_ERROR, + msg="Unknown tag should be rejected", + ), + AdminTestCase( + "write_concern_w0_rejected", + command={"setDefaultRWConcern": 1, "defaultWriteConcern": {"w": 0}}, + error_code=BAD_VALUE_ERROR, + msg="w:0 unsupported as default", + ), + AdminTestCase( + "read_level_non_string_type", + command={"setDefaultRWConcern": 1, "defaultReadConcern": {"level": 123}}, + error_code=TYPE_MISMATCH_ERROR, + msg="level must be string", + ), + AdminTestCase( + "read_concern_extra_key_rejected", + command={"setDefaultRWConcern": 1, "defaultReadConcern": {"level": "local", "extra": 1}}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="Extra key should be rejected", + ), + AdminTestCase( + "non_level_key_in_read_concern", + command={"setDefaultRWConcern": 1, "defaultReadConcern": {"readPreference": "primary"}}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="Should reject non-level key in defaultReadConcern", + ), + AdminTestCase( + "read_level_empty_string_rejected", + command={"setDefaultRWConcern": 1, "defaultReadConcern": {"level": ""}}, + error_code=BAD_VALUE_ERROR, + msg="Empty string level should be rejected", + ), + AdminTestCase( + "read_level_wrong_case_rejected", + command={"setDefaultRWConcern": 1, "defaultReadConcern": {"level": "LOCAL"}}, + error_code=BAD_VALUE_ERROR, + msg="Levels are case-sensitive", + ), + AdminTestCase( + "write_concern_negative_w_rejected", + command={"setDefaultRWConcern": 1, "defaultWriteConcern": {"w": -1}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="Negative w should be rejected", + ), + AdminTestCase( + "write_concern_w_bool_rejected", + command={"setDefaultRWConcern": 1, "defaultWriteConcern": {"w": True}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="boolean w should be rejected", + ), + AdminTestCase( + "write_concern_w_null_rejected", + command={"setDefaultRWConcern": 1, "defaultWriteConcern": {"w": None}}, + error_code=UNKNOWN_REPL_WRITE_CONCERN_ERROR, + msg="null w is coerced to an empty mode name and rejected", + ), + AdminTestCase( + "write_concern_wtimeout_only_rejected", + command={"setDefaultRWConcern": 1, "defaultWriteConcern": {"wtimeout": 100}}, + error_code=BAD_VALUE_ERROR, + msg="wtimeout-only should be rejected", + ), + AdminTestCase( + "write_concern_journal_non_bool_rejected", + command={"setDefaultRWConcern": 1, "defaultWriteConcern": {"w": 1, "j": "true"}}, + error_code=TYPE_MISMATCH_ERROR, + msg="j must be boolean", + ), + AdminTestCase( + "read_concern_nested_level_rejected", + command={"setDefaultRWConcern": 1, "defaultReadConcern": {"level": {"nested": "local"}}}, + error_code=TYPE_MISMATCH_ERROR, + msg="Nested level object should be rejected", + ), + AdminTestCase( + "write_concern_oversized_w_rejected", + command={"setDefaultRWConcern": 1, "defaultWriteConcern": {"w": 9999}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="Oversized w should be rejected", + ), + AdminTestCase( + "write_concern_extra_field_rejected", + command={"setDefaultRWConcern": 1, "defaultWriteConcern": {"w": 1, "unknownField": 1}}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="Extra WC field should be rejected", + ), + AdminTestCase( + "atomic_failure_invalid_read_with_valid_write", + command={ + "setDefaultRWConcern": 1, + "defaultReadConcern": {"level": "snapshot"}, + "defaultWriteConcern": {"w": 1}, + }, + error_code=BAD_VALUE_ERROR, + msg="Should fail atomically when read concern is invalid", + ), + AdminTestCase( + "atomic_failure_invalid_write", + setup_commands=({"setDefaultRWConcern": 1, "defaultReadConcern": {"level": "local"}},), + command={ + "setDefaultRWConcern": 1, + "defaultReadConcern": {"level": "majority"}, + "defaultWriteConcern": {"w": 0}, + }, + error_code=BAD_VALUE_ERROR, + msg="Should fail atomically when write concern is invalid", + ), + AdminTestCase( + "write_concern_cannot_unset_once_set", + setup_commands=({"setDefaultRWConcern": 1, "defaultWriteConcern": {"w": 1}},), + command={"setDefaultRWConcern": 1, "defaultWriteConcern": {}}, + error_code=ILLEGAL_OPERATION_ERROR, + msg="Cannot unset write concern once set", + ), + AdminTestCase( + "set_read_and_unset_write_rejected", + setup_commands=({"setDefaultRWConcern": 1, "defaultWriteConcern": {"w": 1}},), + command={ + "setDefaultRWConcern": 1, + "defaultReadConcern": {"level": "local"}, + "defaultWriteConcern": {}, + }, + error_code=ILLEGAL_OPERATION_ERROR, + msg="Cannot unset write concern even with valid read", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(ERROR_TESTS)) +def test_setDefaultRWConcern_errors(collection, test): + """Run a setDefaultRWConcern error case.""" + for setup in test.setup_commands: + execute_admin_command(collection, setup) + result = execute_admin_command(collection, test.command) + assertResult(result, error_code=test.error_code, msg=test.msg) + + +STATE_INTACT_TESTS: list[AdminTestCase] = [ + AdminTestCase( + "rejected_read_keeps_write_concern", + setup_commands=( + { + "setDefaultRWConcern": 1, + "defaultReadConcern": {"level": "local"}, + "defaultWriteConcern": {"w": "majority"}, + }, + ), + command={ + "setDefaultRWConcern": 1, + "defaultReadConcern": {"level": "snapshot"}, + "defaultWriteConcern": {"w": 1}, + }, + msg="Invalid read concern must not persist the valid write concern", + ), + AdminTestCase( + "rejected_write_keeps_read_concern", + setup_commands=( + { + "setDefaultRWConcern": 1, + "defaultReadConcern": {"level": "local"}, + "defaultWriteConcern": {"w": "majority"}, + }, + ), + command={ + "setDefaultRWConcern": 1, + "defaultReadConcern": {"level": "majority"}, + "defaultWriteConcern": {"w": 0}, + }, + msg="Invalid write concern must not persist the valid read concern", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(STATE_INTACT_TESTS)) +def test_setDefaultRWConcern_failed_set_leaves_defaults_unchanged(collection, test): + """A rejected setDefaultRWConcern must not persist the valid half sent with it.""" + for setup in test.setup_commands: + execute_admin_command(collection, setup) + before = execute_admin_command(collection, {"getDefaultRWConcern": 1}) + + execute_admin_command(collection, test.command) + + after = execute_admin_command(collection, {"getDefaultRWConcern": 1}) + assertResult( + after, + expected={ + "defaultReadConcern": Eq(before["defaultReadConcern"]), + "defaultWriteConcern": Eq(before["defaultWriteConcern"]), + "updateOpTime": Eq(before["updateOpTime"]), + }, + raw_res=True, + msg=test.msg, + ) + + +def test_setDefaultRWConcern_non_admin_database_rejected(collection): + """Test setDefaultRWConcern is rejected when run against a non-admin database.""" + result = execute_command( + collection, {"setDefaultRWConcern": 1, "defaultReadConcern": {"level": "local"}} + ) + assertResult(result, error_code=UNAUTHORIZED_ERROR, msg="Should fail on non-admin database") diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/setDefaultRWConcern/test_setDefaultRWConcern_response_structure.py b/documentdb_tests/compatibility/tests/system/administration/commands/setDefaultRWConcern/test_setDefaultRWConcern_response_structure.py new file mode 100644 index 000000000..146dbc8e2 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/setDefaultRWConcern/test_setDefaultRWConcern_response_structure.py @@ -0,0 +1,75 @@ +"""Response structure tests for setDefaultRWConcern.""" + +import pytest + +from documentdb_tests.compatibility.tests.system.administration.utils.admin_test_case import ( + AdminTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, IsType + +pytestmark = [pytest.mark.admin, pytest.mark.no_parallel, pytest.mark.requires(cluster_admin=True)] + +READ_CONCERN_CMD = {"setDefaultRWConcern": 1, "defaultReadConcern": {"level": "local"}} +WRITE_CONCERN_CMD = {"setDefaultRWConcern": 1, "defaultWriteConcern": {"w": 1}} + + +RESPONSE_TESTS: list[AdminTestCase] = [ + AdminTestCase( + "ok_field", + command=READ_CONCERN_CMD, + expected={"ok": Eq(1.0)}, + msg="success response contains ok field with value 1", + ), + AdminTestCase( + "echoes_write_concern", + command=WRITE_CONCERN_CMD, + expected={"defaultWriteConcern": {"w": Eq(1), "wtimeout": Eq(0)}}, + msg="success response echoes the configured defaultWriteConcern", + ), + AdminTestCase( + "echoes_read_concern", + command=READ_CONCERN_CMD, + expected={"defaultReadConcern": {"level": Eq("local")}}, + msg="success response echoes the configured defaultReadConcern", + ), + AdminTestCase( + "update_op_time_is_timestamp", + command=READ_CONCERN_CMD, + expected={"updateOpTime": IsType("timestamp")}, + msg="success response contains updateOpTime field of timestamp type", + ), + AdminTestCase( + "update_wall_clock_time_is_date", + command=READ_CONCERN_CMD, + expected={"updateWallClockTime": IsType("date")}, + msg="success response contains updateWallClockTime field of date type", + ), + AdminTestCase( + "local_update_wall_clock_time_is_date", + command=READ_CONCERN_CMD, + expected={"localUpdateWallClockTime": IsType("date")}, + msg="success response contains localUpdateWallClockTime field of date type", + ), + AdminTestCase( + "write_concern_source_is_string", + command=READ_CONCERN_CMD, + expected={"defaultWriteConcernSource": IsType("string")}, + msg="success response contains defaultWriteConcernSource field of string type", + ), + AdminTestCase( + "read_concern_source_is_string", + command=READ_CONCERN_CMD, + expected={"defaultReadConcernSource": IsType("string")}, + msg="success response contains defaultReadConcernSource field of string type", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(RESPONSE_TESTS)) +def test_setDefaultRWConcern_response_structure(collection, test): + """Run a setDefaultRWConcern response-structure case.""" + result = execute_admin_command(collection, test.command) + assertResult(result, expected=test.expected, msg=test.msg, raw_res=True) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/setDefaultRWConcern/test_setDefaultRWConcern_valid_inputs.py b/documentdb_tests/compatibility/tests/system/administration/commands/setDefaultRWConcern/test_setDefaultRWConcern_valid_inputs.py new file mode 100644 index 000000000..aa7041fa8 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/setDefaultRWConcern/test_setDefaultRWConcern_valid_inputs.py @@ -0,0 +1,143 @@ +"""Accepted-input tests for setDefaultRWConcern.""" + +import pytest + +from documentdb_tests.compatibility.tests.system.administration.utils.admin_test_case import ( + AdminTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq + +pytestmark = [pytest.mark.admin, pytest.mark.no_parallel, pytest.mark.requires(cluster_admin=True)] + + +VALID_INPUT_TESTS: list[AdminTestCase] = [ + AdminTestCase( + "both_defaults_plus_command_write_concern", + command={ + "setDefaultRWConcern": 1, + "defaultReadConcern": {"level": "local"}, + "defaultWriteConcern": {"w": 1}, + "writeConcern": {"w": 1}, + }, + expected={"ok": Eq(1.0)}, + msg="both defaults plus a command-level writeConcern succeeds", + ), + AdminTestCase( + "empty_read_concern_satisfies_requirement", + command={"setDefaultRWConcern": 1, "defaultReadConcern": {}}, + expected={"ok": Eq(1.0)}, + msg="empty document {} for defaultReadConcern satisfies the at-least-one requirement", + ), + AdminTestCase( + "accepts_command_write_concern", + command={ + "setDefaultRWConcern": 1, + "defaultReadConcern": {"level": "local"}, + "writeConcern": {"w": 1}, + }, + expected={"ok": Eq(1.0)}, + msg="command accepts its own writeConcern field for acknowledgement", + ), + AdminTestCase( + "command_write_concern_w0", + command={ + "setDefaultRWConcern": 1, + "defaultReadConcern": {"level": "local"}, + "writeConcern": {"w": 0}, + }, + expected={"ok": Eq(1.0)}, + msg="command-level writeConcern w:0 should work independently of defaultWriteConcern", + ), + AdminTestCase( + "read_level_local", + command={"setDefaultRWConcern": 1, "defaultReadConcern": {"level": "local"}}, + expected={"ok": Eq(1.0)}, + msg="level 'local' is accepted", + ), + AdminTestCase( + "read_level_available", + command={"setDefaultRWConcern": 1, "defaultReadConcern": {"level": "available"}}, + expected={"ok": Eq(1.0)}, + msg="level 'available' is accepted", + ), + AdminTestCase( + "read_level_majority", + command={"setDefaultRWConcern": 1, "defaultReadConcern": {"level": "majority"}}, + expected={"ok": Eq(1.0)}, + msg="level 'majority' is accepted", + ), + AdminTestCase( + "write_concern_w_majority", + command={"setDefaultRWConcern": 1, "defaultWriteConcern": {"w": "majority"}}, + expected={"ok": Eq(1.0)}, + msg="w:'majority' is accepted", + ), + AdminTestCase( + "write_concern_journal_flag", + command={"setDefaultRWConcern": 1, "defaultWriteConcern": {"w": 1, "j": True}}, + expected={"ok": Eq(1.0)}, + msg="journal/j boolean flag is accepted", + ), + AdminTestCase( + "write_concern_fractional_w", + command={"setDefaultRWConcern": 1, "defaultWriteConcern": {"w": 1.5}}, + expected={"ok": Eq(1.0)}, + msg="fractional double w is accepted", + ), + AdminTestCase( + "write_concern_wtimeout_string", + command={"setDefaultRWConcern": 1, "defaultWriteConcern": {"w": 1, "wtimeout": "100"}}, + expected={"ok": Eq(1.0)}, + msg="wtimeout as string is accepted (coerced)", + ), + AdminTestCase( + "write_concern_negative_wtimeout", + command={"setDefaultRWConcern": 1, "defaultWriteConcern": {"w": 1, "wtimeout": -1}}, + expected={"ok": Eq(1.0)}, + msg="negative wtimeout is accepted (coerced)", + ), + AdminTestCase( + "write_concern_wtimeout_stored", + command={"setDefaultRWConcern": 1, "defaultWriteConcern": {"w": 1, "wtimeout": 5000}}, + expected={"ok": Eq(1.0), "defaultWriteConcern": {"w": Eq(1), "wtimeout": Eq(5000)}}, + msg="wtimeout should be stored", + ), + AdminTestCase( + "write_concern_wtimeout_defaults_to_zero", + command={"setDefaultRWConcern": 1, "defaultWriteConcern": {"w": 1}}, + expected={"ok": Eq(1.0), "defaultWriteConcern": {"w": Eq(1), "wtimeout": Eq(0)}}, + msg="wtimeout should default to 0", + ), + AdminTestCase( + "read_concern_null_as_omitted", + command={ + "setDefaultRWConcern": 1, + "defaultReadConcern": None, + "defaultWriteConcern": {"w": 1}, + }, + expected={"ok": Eq(1.0)}, + msg="defaultReadConcern null is treated as omitted", + ), + AdminTestCase( + "write_concern_null_as_omitted", + command={ + "setDefaultRWConcern": 1, + "defaultWriteConcern": None, + "defaultReadConcern": {"level": "local"}, + }, + expected={"ok": Eq(1.0)}, + msg="defaultWriteConcern null is treated as omitted", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(VALID_INPUT_TESTS)) +def test_setDefaultRWConcern_valid_inputs(collection, test): + """Run a setDefaultRWConcern accepted-input case.""" + for setup in test.setup_commands: + execute_admin_command(collection, setup) + result = execute_admin_command(collection, test.command) + assertResult(result, expected=test.expected, msg=test.msg, raw_res=True) diff --git a/documentdb_tests/compatibility/tests/system/administration/utils/admin_test_case.py b/documentdb_tests/compatibility/tests/system/administration/utils/admin_test_case.py index 1780e1416..7e3a19c36 100644 --- a/documentdb_tests/compatibility/tests/system/administration/utils/admin_test_case.py +++ b/documentdb_tests/compatibility/tests/system/administration/utils/admin_test_case.py @@ -8,15 +8,14 @@ @dataclass(frozen=True) class AdminTestCase(BaseTestCase): - """Test case for administration command tests. - - Inherits ``id``, ``expected``, ``error_code``, ``msg``, and ``marks`` from - ``BaseTestCase``. + """Test case for an administration command. Attributes: command: The command document to execute. use_admin: If True, execute against the admin database. + setup_commands: Commands to run before the test command. """ command: Optional[Dict[str, Any]] = None use_admin: bool = True + setup_commands: tuple[Dict[str, Any], ...] = () From 6650c521c5b65d573901704848b8b0c0448be51c Mon Sep 17 00:00:00 2001 From: "Paterson.How" <71473631+PatersonProjects@users.noreply.github.com> Date: Mon, 13 Jul 2026 11:23:20 -0700 Subject: [PATCH 18/35] Add explain diagnostic tests (#631) Signed-off-by: PatersonProjects Signed-off-by: RyanGarfinkel <113050972+RyanGarfinkel@users.noreply.github.com> --- .../diagnostic/commands/explain/__init__.py | 0 .../test_explain_argument_validation.py | 105 ++++ .../explain/test_explain_core_behavior.py | 251 ++++++++ .../commands/explain/test_explain_errors.py | 93 +++ .../explain/test_explain_geo_queries.py | 98 +++ .../explain/test_explain_query_plans.py | 161 +++++ .../test_explain_queryhash_pipeline.py | 389 ++++++++++++ .../explain/test_explain_queryhash_shape.py | 577 ++++++++++++++++++ .../test_explain_verbosity_and_response.py | 243 ++++++++ .../explain/test_explain_write_operations.py | 145 +++++ 10 files changed, 2062 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_argument_validation.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_core_behavior.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_errors.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_geo_queries.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_query_plans.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_queryhash_pipeline.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_queryhash_shape.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_verbosity_and_response.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_write_operations.py diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/__init__.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_argument_validation.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_argument_validation.py new file mode 100644 index 000000000..a4c638efb --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_argument_validation.py @@ -0,0 +1,105 @@ +"""Tests for explain command argument validation. + +Covers the verbosity parameter (valid string modes and null, rejection of other +BSON types), the explain field (rejection of non-document types), and the +comment parameter (acceptance of any BSON type). +""" + +import pytest + +from documentdb_tests.framework.assertions import ( + assertFailureCode, + assertSuccessPartial, +) +from documentdb_tests.framework.bson_type_validator import ( + BsonTypeTestCase, + generate_bson_acceptance_test_cases, + generate_bson_rejection_test_cases, +) +from documentdb_tests.framework.error_codes import MISSING_FIELD_ERROR, TYPE_MISMATCH_ERROR +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.test_constants import BsonType + +pytestmark = pytest.mark.admin + + +VERBOSITY_SPEC = [ + BsonTypeTestCase( + id="verbosity", + msg=( + "verbosity should accept string modes and null, " + "and reject other types with TypeMismatch" + ), + keyword="verbosity", + valid_types=[BsonType.STRING, BsonType.NULL], + valid_inputs={BsonType.STRING: "queryPlanner"}, + default_error_code=TYPE_MISMATCH_ERROR, + ) +] +VERBOSITY_ACCEPTANCE_CASES = generate_bson_acceptance_test_cases(VERBOSITY_SPEC) +VERBOSITY_REJECTION_CASES = generate_bson_rejection_test_cases(VERBOSITY_SPEC) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", VERBOSITY_ACCEPTANCE_CASES) +def test_explain_accepts_valid_verbosity(collection, bson_type, sample_value, spec): + """Test explain accepts valid verbosity values (string mode and null default).""" + collection.insert_one({"_id": 1, "a": 1}) + result = execute_command( + collection, + {"explain": {"find": collection.name, "filter": {"a": 1}}, "verbosity": sample_value}, + ) + assertSuccessPartial(result, {"ok": 1.0}, msg=spec.msg) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", VERBOSITY_REJECTION_CASES) +def test_explain_rejects_non_string_verbosity(collection, bson_type, sample_value, spec): + """Test explain rejects non-string verbosity for every invalid BSON type.""" + collection.insert_one({"_id": 1, "a": 1}) + result = execute_command( + collection, + {"explain": {"find": collection.name, "filter": {"a": 1}}, "verbosity": sample_value}, + ) + assertFailureCode(result, spec.expected_code(bson_type), msg=spec.msg) + + +EXPLAIN_ARGUMENT_TYPE_SPEC = [ + BsonTypeTestCase( + id="explain", + msg="explain field should reject non-document types with TypeMismatch", + keyword="explain", + valid_types=[BsonType.OBJECT], + default_error_code=TYPE_MISMATCH_ERROR, + error_code_overrides={BsonType.NULL: MISSING_FIELD_ERROR}, + ) +] +EXPLAIN_ARGUMENT_REJECTION_CASES = generate_bson_rejection_test_cases(EXPLAIN_ARGUMENT_TYPE_SPEC) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", EXPLAIN_ARGUMENT_REJECTION_CASES) +def test_explain_rejects_non_document_explain_field(collection, bson_type, sample_value, spec): + """Test explain rejects non-document values for the explain field.""" + result = execute_command( + collection, + {"explain": sample_value, "verbosity": "queryPlanner"}, + ) + assertFailureCode(result, spec.expected_code(bson_type), msg=spec.msg) + + +COMMENT_TYPE_SPEC = [ + BsonTypeTestCase( + id="comment", + msg="comment should accept any BSON type", + keyword="comment", + valid_types=list(BsonType), + ) +] +COMMENT_ACCEPTANCE_CASES = generate_bson_acceptance_test_cases(COMMENT_TYPE_SPEC) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", COMMENT_ACCEPTANCE_CASES) +def test_explain_accepts_comment_type(collection, bson_type, sample_value, spec): + """Test explain accepts a comment of any BSON type at the explain level.""" + collection.insert_one({"_id": 1, "a": 1}) + cmd = {"explain": {"find": collection.name, "filter": {"a": 1}}, "comment": sample_value} + result = execute_command(collection, cmd) + assertSuccessPartial(result, {"ok": 1.0}, msg=spec.msg) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_core_behavior.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_core_behavior.py new file mode 100644 index 000000000..14528171d --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_core_behavior.py @@ -0,0 +1,251 @@ +"""Tests for explain command core behavior. + +Covers the explainable command surface (find, aggregate, count, distinct, +update, delete, findAndModify), edge cases (empty / non-existent collection, +complex and $expr filters, $lookup sub-pipeline), and plan-cache interaction +(explain does not create plan cache entries). +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.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.property_checks import Eq, Exists + +pytestmark = pytest.mark.admin + + +SUPPORTED_COMMAND_TESTS: list[CommandTestCase] = [ + CommandTestCase( + id="find", + docs=[{"_id": i, "a": i, "b": i} for i in range(5)], + command=lambda ctx: {"explain": {"find": ctx.collection, "filter": {"a": 1}}}, + expected={"ok": Eq(1.0)}, + msg="explain should plan the find command", + ), + CommandTestCase( + id="aggregate_single", + docs=[{"_id": i, "a": i, "b": i} for i in range(5)], + command=lambda ctx: { + "explain": { + "aggregate": ctx.collection, + "pipeline": [{"$match": {"a": 1}}], + "cursor": {}, + } + }, + expected={"ok": Eq(1.0)}, + msg="explain should plan the aggregate_single command", + ), + CommandTestCase( + id="aggregate_multi", + docs=[{"_id": i, "a": i, "b": i} for i in range(5)], + command=lambda ctx: { + "explain": { + "aggregate": ctx.collection, + "pipeline": [ + {"$match": {"a": {"$gt": 0}}}, + {"$group": {"_id": None, "c": {"$sum": 1}}}, + ], + "cursor": {}, + } + }, + expected={"ok": Eq(1.0)}, + msg="explain should plan the aggregate_multi command", + ), + CommandTestCase( + id="count", + docs=[{"_id": i, "a": i, "b": i} for i in range(5)], + command=lambda ctx: {"explain": {"count": ctx.collection, "query": {"a": 1}}}, + expected={"ok": Eq(1.0)}, + msg="explain should plan the count command", + ), + CommandTestCase( + id="distinct", + docs=[{"_id": i, "a": i, "b": i} for i in range(5)], + command=lambda ctx: {"explain": {"distinct": ctx.collection, "key": "a"}}, + expected={"ok": Eq(1.0)}, + msg="explain should plan the distinct command", + ), + CommandTestCase( + id="update_single", + docs=[{"_id": i, "a": i, "b": i} for i in range(5)], + command=lambda ctx: { + "explain": { + "update": ctx.collection, + "updates": [{"q": {"a": 1}, "u": {"$set": {"b": 9}}}], + } + }, + expected={"ok": Eq(1.0)}, + msg="explain should plan the update_single command", + ), + CommandTestCase( + id="update_multi", + docs=[{"_id": i, "a": i, "b": i} for i in range(5)], + command=lambda ctx: { + "explain": { + "update": ctx.collection, + "updates": [{"q": {"a": {"$gt": 0}}, "u": {"$set": {"b": 9}}, "multi": True}], + } + }, + expected={"ok": Eq(1.0)}, + msg="explain should plan the update_multi command", + ), + CommandTestCase( + id="delete_single", + docs=[{"_id": i, "a": i, "b": i} for i in range(5)], + command=lambda ctx: { + "explain": {"delete": ctx.collection, "deletes": [{"q": {"a": 1}, "limit": 1}]} + }, + expected={"ok": Eq(1.0)}, + msg="explain should plan the delete_single command", + ), + CommandTestCase( + id="delete_multi", + docs=[{"_id": i, "a": i, "b": i} for i in range(5)], + command=lambda ctx: { + "explain": { + "delete": ctx.collection, + "deletes": [{"q": {"a": {"$gt": 0}}, "limit": 0}], + } + }, + expected={"ok": Eq(1.0)}, + msg="explain should plan the delete_multi command", + ), + CommandTestCase( + id="findAndModify", + docs=[{"_id": i, "a": i, "b": i} for i in range(5)], + command=lambda ctx: { + "explain": { + "findAndModify": ctx.collection, + "query": {"a": 1}, + "update": {"$set": {"b": 9}}, + } + }, + expected={"ok": Eq(1.0)}, + msg="explain should plan the findAndModify command", + ), + CommandTestCase( + id="find_returns_query_planner", + docs=[{"_id": i, "a": i, "b": i} for i in range(5)], + command=lambda ctx: {"explain": {"find": ctx.collection, "filter": {"a": 1}}}, + expected={"queryPlanner": Exists()}, + msg="find explain has queryPlanner", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(SUPPORTED_COMMAND_TESTS)) +def test_explain_supported_commands(collection, test): + """Test explain plans each supported command and exposes planner output.""" + collection = test.prepare(collection.database, 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, + ) + + +EDGE_CASE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + id="empty_collection", + docs=[], + command=lambda ctx: {"explain": {"find": ctx.collection, "filter": {"a": 1}}}, + expected={"ok": Eq(1.0)}, + msg="explain on empty collection should succeed", + ), + CommandTestCase( + id="non_existent_collection", + command=lambda ctx: { + "explain": {"find": f"{ctx.collection}_nonexistent", "filter": {"a": 1}} + }, + expected={"ok": Eq(1.0)}, + msg="explain on non-existent collection should succeed", + ), + CommandTestCase( + id="complex_nested_query", + docs=[{"_id": i, "a": i, "b": i % 2} for i in range(10)], + command=lambda ctx: { + "explain": { + "find": ctx.collection, + "filter": { + "$and": [ + {"$or": [{"a": {"$lt": 3}}, {"a": {"$gt": 7}}]}, + {"$or": [{"b": 0}, {"b": 1}]}, + ] + }, + } + }, + expected={"ok": Eq(1.0)}, + msg="explain should plan a complex nested query", + ), + CommandTestCase( + id="expr_in_filter", + docs=[{"_id": i, "a": i, "b": i + 1} for i in range(5)], + command=lambda ctx: { + "explain": {"find": ctx.collection, "filter": {"$expr": {"$gt": ["$b", "$a"]}}} + }, + expected={"ok": Eq(1.0)}, + msg="explain should plan an $expr filter", + ), + CommandTestCase( + id="aggregate_lookup_subpipeline", + docs=[{"_id": i, "a": i} for i in range(5)], + command=lambda ctx: { + "explain": { + "aggregate": ctx.collection, + "pipeline": [ + { + "$lookup": { + "from": ctx.collection, + "let": {"av": "$a"}, + "pipeline": [{"$match": {"$expr": {"$eq": ["$a", "$$av"]}}}], + "as": "matches", + } + } + ], + "cursor": {}, + } + }, + expected={"ok": Eq(1.0)}, + msg="explain should plan a $lookup sub-pipeline", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(EDGE_CASE_TESTS)) +def test_explain_edge_cases(collection, test): + """Test explain succeeds across collection-state and filter edge cases.""" + collection = test.prepare(collection.database, 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, + ) + + +def test_explain_does_not_create_plan_cache_entry(collection): + """Test explain does not create a plan cache entry for the planned query.""" + collection.insert_many([{"_id": i, "a": i % 5, "b": i % 3} for i in range(50)]) + collection.create_index([("a", 1)]) + collection.create_index([("a", 1), ("b", 1)]) + collection.database.command({"planCacheClear": collection.name}) + execute_command(collection, {"explain": {"find": collection.name, "filter": {"a": 2, "b": 1}}}) + + cache = execute_command( + collection, + {"aggregate": collection.name, "pipeline": [{"$planCacheStats": {}}], "cursor": {}}, + ) + assertSuccess(cache, [], msg="explain should not create plan cache entries") diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_errors.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_errors.py new file mode 100644 index 000000000..2474517c3 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_errors.py @@ -0,0 +1,93 @@ +"""Tests for explain command error conditions. + +Covers invalid verbosity strings, invalid and non-explainable explain field +values, and geospatial explain errors (hinting a non-geo index with +$nearSphere). +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode, assertResult +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + COMMAND_NOT_FOUND_ERROR, + ILLEGAL_OPERATION_ERROR, + NO_QUERY_EXECUTION_PLANS_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = pytest.mark.admin + +EXPLAIN_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + id="invalid_verbosity", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "explain": {"find": ctx.collection, "filter": {"a": 1}}, + "verbosity": "notAMode", + }, + error_code=BAD_VALUE_ERROR, + msg="invalid verbosity string should be BadValue", + ), + CommandTestCase( + id="non_explainable_command", + command=lambda ctx: {"explain": {"insert": ctx.collection, "documents": [{"_id": 1}]}}, + error_code=ILLEGAL_OPERATION_ERROR, + msg="non-explainable command should error", + ), + CommandTestCase( + id="empty_explain_document", + command={"explain": {}}, + error_code=COMMAND_NOT_FOUND_ERROR, + msg="empty explain document should error", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(EXPLAIN_ERROR_TESTS)) +def test_explain_error_cases(collection, test): + """Test explain rejects invalid verbosity, non-explainable commands, + and malformed explain fields. + """ + collection = test.prepare(collection.database, 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, + ) + + +@pytest.mark.geospatial +def test_explain_find_nearSphere_hint_non_geo_index_fails(collection): + """Test explain of a $nearSphere find hinting a non-geo index returns an error.""" + collection.create_index([("loc", "2dsphere")]) + collection.insert_many( + [ + {"_id": 0, "loc": {"type": "Point", "coordinates": [0, 0]}, "a": 1}, + {"_id": 1, "loc": {"type": "Point", "coordinates": [1, 1]}, "a": 2}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [2, 2]}, "a": 3}, + ] + ) + collection.create_index([("a", 1)]) + near = {"loc": {"$nearSphere": {"$geometry": {"type": "Point", "coordinates": [0, 0]}}}} + result = execute_command( + collection, + { + "explain": {"find": collection.name, "filter": near, "hint": {"a": 1}}, + "verbosity": "queryPlanner", + }, + ) + assertFailureCode( + result, + NO_QUERY_EXECUTION_PLANS_ERROR, + msg="$nearSphere with a non-geo index hint should fail (no viable plan)", + ) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_geo_queries.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_geo_queries.py new file mode 100644 index 000000000..0c8a70513 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_geo_queries.py @@ -0,0 +1,98 @@ +"""Tests for explain wiring with geospatial $nearSphere queries. + +These are wiring tests: they verify that explain plans $nearSphere queries +across command types (find, count, distinct, findAndModify). Comprehensive +geospatial parsing and semantics live under the geospatial feature directory; +here we only confirm explain delegates to the geo query path. +""" + +import pytest +from pymongo import IndexModel + +from documentdb_tests.compatibility.tests.core.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.property_checks import Eq + +pytestmark = [pytest.mark.admin, pytest.mark.geospatial] + + +NEAR = {"loc": {"$nearSphere": {"$geometry": {"type": "Point", "coordinates": [0, 0]}}}} + +GEO_DOCS = [ + {"_id": 0, "loc": {"type": "Point", "coordinates": [0, 0]}, "a": 1}, + {"_id": 1, "loc": {"type": "Point", "coordinates": [1, 1]}, "a": 2}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [2, 2]}, "a": 3}, +] +GEO_INDEXES = [IndexModel([("loc", "2dsphere")])] + + +NEARSPHERE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + id="find_basic", + docs=GEO_DOCS, + indexes=GEO_INDEXES, + command=lambda ctx: { + "explain": {"find": ctx.collection, "filter": NEAR}, + "verbosity": "queryPlanner", + }, + expected={"ok": Eq(1.0)}, + msg="explain find $nearSphere (basic) should plan", + ), + CommandTestCase( + id="count", + docs=GEO_DOCS, + indexes=GEO_INDEXES, + command=lambda ctx: { + "explain": {"count": ctx.collection, "query": NEAR}, + "verbosity": "queryPlanner", + }, + expected={"ok": Eq(1.0)}, + msg="explain count $nearSphere should plan", + ), + CommandTestCase( + id="distinct", + docs=GEO_DOCS, + indexes=GEO_INDEXES, + command=lambda ctx: { + "explain": {"distinct": ctx.collection, "key": "a", "query": NEAR}, + "verbosity": "queryPlanner", + }, + expected={"ok": Eq(1.0)}, + msg="explain distinct $nearSphere should plan", + ), + CommandTestCase( + id="findAndModify", + docs=GEO_DOCS, + indexes=GEO_INDEXES, + command=lambda ctx: { + "explain": { + "findAndModify": ctx.collection, + "query": NEAR, + "update": {"$set": {"visited": True}}, + }, + "verbosity": "queryPlanner", + }, + expected={"ok": Eq(1.0)}, + msg="explain findAndModify $nearSphere should plan", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(NEARSPHERE_TESTS)) +def test_explain_nearSphere_plans(collection, test): + """Test explain plans $nearSphere queries across command types.""" + collection = test.prepare(collection.database, 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/compatibility/tests/system/diagnostic/commands/explain/test_explain_query_plans.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_query_plans.py new file mode 100644 index 000000000..a1c338acc --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_query_plans.py @@ -0,0 +1,161 @@ +"""Tests for explain query-plan and index-usage output. + +Covers collection-scan vs index-scan plan selection, the plan change after an +index is created, aggregate query-planner output, and executionStats counts for +hinted queries and $limit pipelines (including after additional inserts). + +Assertions favor portable signals (nReturned, plan stage presence) over +engine-specific internal plan-node details. +""" + +import pytest +from pymongo import IndexModel + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import ( + assertProperties, + assertResult, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, Exists, Ne + +pytestmark = pytest.mark.admin + + +def test_explain_uses_collscan_without_index(collection): + """Test explain reports COLLSCAN when no usable index exists.""" + collection.insert_many([{"_id": i, "a": i} for i in range(20)]) + result = execute_command(collection, {"explain": {"find": collection.name, "filter": {"a": 5}}}) + assertProperties( + result, + {"queryPlanner.winningPlan.stage": Eq("COLLSCAN")}, + raw_res=True, + msg="plan should be COLLSCAN before index creation", + ) + + +def test_explain_avoids_collscan_after_index_created(collection): + """Test explain stops using COLLSCAN after a matching index is created.""" + collection.insert_many([{"_id": i, "a": i} for i in range(20)]) + collection.create_index([("a", 1)]) + result = execute_command(collection, {"explain": {"find": collection.name, "filter": {"a": 5}}}) + assertProperties( + result, + {"queryPlanner.winningPlan.stage": Ne("COLLSCAN")}, + raw_res=True, + msg="plan should use an index (not COLLSCAN) after index creation", + ) + + +def test_explain_aggregate_contains_query_planner(collection): + """Test explain for an aggregate returns a queryPlanner section.""" + collection.insert_many([{"_id": i, "a": i % 3} for i in range(20)]) + result = execute_command( + collection, + { + "explain": { + "aggregate": collection.name, + "pipeline": [{"$match": {"a": 1}}], + "cursor": {}, + } + }, + ) + assertProperties( + result, + {"queryPlanner": Exists()}, + raw_res=True, + msg="aggregate explain should contain a queryPlanner section", + ) + + +EXEC_STATS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + id="hint_nReturned", + docs=[{"_id": i, "a": i, "b": i % 2} for i in range(10)], + indexes=[IndexModel([("a", 1)])], + command=lambda ctx: { + "explain": { + "find": ctx.collection, + "filter": {"a": {"$gte": 0}}, + "sort": {"a": 1}, + "hint": {"a": 1}, + }, + "verbosity": "executionStats", + }, + expected={"executionStats.nReturned": Eq(10)}, + msg="explain with hint should report correct nReturned", + ), + CommandTestCase( + id="limit_executionStats_nReturned", + docs=[{"_id": i, "a": i} for i in range(20)], + command=lambda ctx: { + "explain": {"aggregate": ctx.collection, "pipeline": [{"$limit": 5}], "cursor": {}}, + "verbosity": "executionStats", + }, + expected={"executionStats.nReturned": Eq(5)}, + msg="executionStats nReturned should match the limit", + ), + CommandTestCase( + id="limit_allPlansExecution_nReturned", + docs=[{"_id": i, "a": i} for i in range(20)], + command=lambda ctx: { + "explain": {"aggregate": ctx.collection, "pipeline": [{"$limit": 5}], "cursor": {}}, + "verbosity": "allPlansExecution", + }, + expected={"executionStats.nReturned": Eq(5)}, + msg="allPlansExecution should honor the limit", + ), + CommandTestCase( + id="limit_multiple_indexes_nReturned", + docs=[{"_id": i, "a": i % 5, "b": i % 3} for i in range(60)], + indexes=[IndexModel([("a", 1)]), IndexModel([("a", 1), ("b", 1)])], + command=lambda ctx: { + "explain": { + "aggregate": ctx.collection, + "pipeline": [{"$match": {"a": 2, "b": 1}}, {"$limit": 3}], + "cursor": {}, + }, + "verbosity": "executionStats", + }, + expected={"executionStats.nReturned": Eq(3)}, + msg="limit should be honored with multiple candidate indexes", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(EXEC_STATS_TESTS)) +def test_explain_execution_stats(collection, test): + """Test explain executionStats reports counts matching hints and limits.""" + collection = test.prepare(collection.database, 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, + ) + + +def test_explain_results_update_after_inserts(collection): + """Test explain executionStats reflects newly inserted documents.""" + collection.insert_many([{"_id": i, "a": i} for i in range(10)]) + collection.create_index([("a", 1)]) + query = { + "explain": {"find": collection.name, "filter": {"a": {"$gte": 0}}, "hint": {"a": 1}}, + "verbosity": "executionStats", + } + execute_command(collection, query) + collection.insert_many([{"_id": i, "a": i} for i in range(10, 15)]) + after = execute_command(collection, query) + assertProperties( + after, + {"executionStats.nReturned": Eq(15)}, + raw_res=True, + msg="explain should reflect additional inserted documents", + ) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_queryhash_pipeline.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_queryhash_pipeline.py new file mode 100644 index 000000000..b185d5f16 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_queryhash_pipeline.py @@ -0,0 +1,389 @@ +"""Tests demonstrating queryShapeHash vs planCacheShapeHash divergence for pipelines. + +planCacheShapeHash considers the filter, sort, projection, and certain pipeline +stages that affect plan selection ($group, $lookup, $bucket, $replaceRoot, +$project, $sort, $count, $sortByCount, $densify, $setWindowFields). +It ignores stages like $unwind, $addFields, $limit, $skip, $set, $unset, +$sample, $out, $facet, $unionWith, $redact, $fill, and $merge. +queryShapeHash considers all stages in the pipeline. + +These tests verify that two pipelines sharing the same $match but differing +in a single downstream stage produce expected hash equality/inequality based +on MongoDB's actual behavior: + - Stages ignored by the planner leave planCacheShapeHash unchanged. + - Stages considered by the planner change planCacheShapeHash. + - queryShapeHash always reflects any pipeline difference. +""" + +from dataclasses import dataclass, field +from typing import Any + +import pytest + +from documentdb_tests.framework.assertions import assertProperties +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, Ne +from documentdb_tests.framework.test_case import BaseTestCase + +pytestmark = pytest.mark.admin + + +def _get_plan_cache_shape_hash(collection, pipeline: list) -> str: + """Return queryPlanner.planCacheShapeHash from explain for an aggregate pipeline.""" + cmd: dict[str, Any] = { + "aggregate": collection.name, + "pipeline": pipeline, + "cursor": {}, + } + result = execute_command(collection, {"explain": cmd, "verbosity": "queryPlanner"}) + if isinstance(result, Exception): + raise RuntimeError(f"explain command failed: {result}") + # Standard format: queryPlanner at top level. + query_planner = result.get("queryPlanner", {}) + if "planCacheShapeHash" in query_planner: + return str(query_planner["planCacheShapeHash"]) + # Stages format ($out/$merge): nested under stages.0.$cursor.queryPlanner. + stages = result.get("stages", []) + if stages: + cursor_stage = stages[0].get("$cursor", {}) + qp = cursor_stage.get("queryPlanner", {}) + if "planCacheShapeHash" in qp: + return str(qp["planCacheShapeHash"]) + raise RuntimeError( + f"explain did not return planCacheShapeHash in expected location; got: {result}" + ) + + +def _get_query_shape_hash(collection, pipeline: list) -> str: + """Return the top-level queryShapeHash from explain for an aggregate pipeline.""" + cmd: dict[str, Any] = { + "aggregate": collection.name, + "pipeline": pipeline, + "cursor": {}, + } + result = execute_command(collection, {"explain": cmd, "verbosity": "queryPlanner"}) + if isinstance(result, Exception): + raise RuntimeError(f"explain command failed: {result}") + if "queryShapeHash" not in result: + raise RuntimeError(f"explain did not return queryShapeHash; got: {result}") + return str(result["queryShapeHash"]) + + +@dataclass(frozen=True) +class PipelineHashCase(BaseTestCase): + """Test case comparing hashes for two aggregate pipelines. + + Attributes: + pipeline_a: First aggregate pipeline (baseline with only $match). + pipeline_b: Second aggregate pipeline ($match + one additional stage). + plan_cache_same: Whether planCacheShapeHash should match. + query_shape_same: Whether queryShapeHash should match. + """ + + pipeline_a: tuple = field(default_factory=tuple) + pipeline_b: tuple = field(default_factory=tuple) + plan_cache_same: bool = True + query_shape_same: bool = False + + +# --------------------------------------------------------------------------- +# Tests verifying which pipeline stages affect planCacheShapeHash when added +# after a shared $match. Each test compares a baseline pipeline (just $match) +# against the same $match followed by one additional stage. +# +# queryShapeHash always differs when any stage is added. +# planCacheShapeHash only differs for stages the query planner considers. +# --------------------------------------------------------------------------- + +PIPELINE_DIVERGENCE_TESTS: list[PipelineHashCase] = [ + # Property [Stages included in planCacheShapeHash]: these stages change + # planCacheShapeHash when added after $match + PipelineHashCase( + id="group_presence", + pipeline_a=({"$match": {"a": {"$gt": 0}}},), + pipeline_b=({"$match": {"a": {"$gt": 0}}}, {"$group": {"_id": "$b", "c": {"$sum": 1}}}), + plan_cache_same=False, + query_shape_same=False, + msg=( + "adding $group after $match should change both planCacheShapeHash " "and queryShapeHash" + ), + ), + PipelineHashCase( + id="lookup_presence", + pipeline_a=({"$match": {"a": {"$gt": 0}}},), + pipeline_b=( + {"$match": {"a": {"$gt": 0}}}, + { + "$lookup": { + "from": "other", + "localField": "a", + "foreignField": "_id", + "as": "joined", + } + }, + ), + plan_cache_same=False, + query_shape_same=False, + msg=("adding $lookup should change both planCacheShapeHash " "and queryShapeHash"), + ), + PipelineHashCase( + id="bucket_presence", + pipeline_a=({"$match": {"a": {"$gt": 0}}},), + pipeline_b=( + {"$match": {"a": {"$gt": 0}}}, + {"$bucket": {"groupBy": "$a", "boundaries": [0, 5, 10]}}, + ), + plan_cache_same=False, + query_shape_same=False, + msg=("adding $bucket should change both planCacheShapeHash " "and queryShapeHash"), + ), + PipelineHashCase( + id="replaceRoot_presence", + pipeline_a=({"$match": {"a": {"$gt": 0}}},), + pipeline_b=( + {"$match": {"a": {"$gt": 0}}}, + {"$replaceRoot": {"newRoot": "$sub"}}, + ), + plan_cache_same=False, + query_shape_same=False, + msg=("adding $replaceRoot should change both planCacheShapeHash " "and queryShapeHash"), + ), + PipelineHashCase( + id="project_presence", + pipeline_a=({"$match": {"a": {"$gt": 0}}},), + pipeline_b=({"$match": {"a": {"$gt": 0}}}, {"$project": {"a": 1}}), + plan_cache_same=False, + query_shape_same=False, + msg=("adding $project should change both planCacheShapeHash " "and queryShapeHash"), + ), + PipelineHashCase( + id="sort_presence", + pipeline_a=({"$match": {"a": {"$gt": 0}}},), + pipeline_b=({"$match": {"a": {"$gt": 0}}}, {"$sort": {"a": 1}}), + plan_cache_same=False, + query_shape_same=False, + msg=("adding $sort should change both planCacheShapeHash " "and queryShapeHash"), + ), + PipelineHashCase( + id="count_presence", + pipeline_a=({"$match": {"a": {"$gt": 0}}},), + pipeline_b=({"$match": {"a": {"$gt": 0}}}, {"$count": "total"}), + plan_cache_same=False, + query_shape_same=False, + msg=("adding $count should change both planCacheShapeHash " "and queryShapeHash"), + ), + PipelineHashCase( + id="sortByCount_presence", + pipeline_a=({"$match": {"a": {"$gt": 0}}},), + pipeline_b=({"$match": {"a": {"$gt": 0}}}, {"$sortByCount": "$a"}), + plan_cache_same=False, + query_shape_same=False, + msg=("adding $sortByCount should change both planCacheShapeHash " "and queryShapeHash"), + ), + PipelineHashCase( + id="densify_presence", + pipeline_a=({"$match": {"a": {"$gt": 0}}},), + pipeline_b=( + {"$match": {"a": {"$gt": 0}}}, + {"$densify": {"field": "a", "range": {"step": 1, "bounds": "full"}}}, + ), + plan_cache_same=False, + query_shape_same=False, + msg=("adding $densify should change both planCacheShapeHash " "and queryShapeHash"), + ), + PipelineHashCase( + id="setWindowFields_presence", + pipeline_a=({"$match": {"a": {"$gt": 0}}},), + pipeline_b=( + {"$match": {"a": {"$gt": 0}}}, + {"$setWindowFields": {"sortBy": {"a": 1}, "output": {"rank": {"$rank": {}}}}}, + ), + plan_cache_same=False, + query_shape_same=False, + msg=("adding $setWindowFields should change both planCacheShapeHash " "and queryShapeHash"), + ), + # Property [Stages ignored by planCacheShapeHash]: these stages do not change + # planCacheShapeHash but do change queryShapeHash + PipelineHashCase( + id="unwind_presence", + pipeline_a=({"$match": {"a": {"$gt": 0}}},), + pipeline_b=({"$match": {"a": {"$gt": 0}}}, {"$unwind": "$arr"}), + plan_cache_same=True, + query_shape_same=False, + msg=( + "adding $unwind should not change planCacheShapeHash " + "but should change queryShapeHash" + ), + ), + PipelineHashCase( + id="addFields_presence", + pipeline_a=({"$match": {"a": {"$gt": 0}}},), + pipeline_b=( + {"$match": {"a": {"$gt": 0}}}, + {"$addFields": {"x": {"$add": ["$a", 1]}}}, + ), + plan_cache_same=True, + query_shape_same=False, + msg=( + "adding $addFields should not change planCacheShapeHash " + "but should change queryShapeHash" + ), + ), + PipelineHashCase( + id="limit_presence", + pipeline_a=({"$match": {"a": {"$gt": 0}}},), + pipeline_b=({"$match": {"a": {"$gt": 0}}}, {"$limit": 5}), + plan_cache_same=True, + query_shape_same=False, + msg=( + "adding $limit should not change planCacheShapeHash " "but should change queryShapeHash" + ), + ), + PipelineHashCase( + id="skip_presence", + pipeline_a=({"$match": {"a": {"$gt": 0}}},), + pipeline_b=({"$match": {"a": {"$gt": 0}}}, {"$skip": 2}), + plan_cache_same=True, + query_shape_same=False, + msg=( + "adding $skip should not change planCacheShapeHash " "but should change queryShapeHash" + ), + ), + PipelineHashCase( + id="set_presence", + pipeline_a=({"$match": {"a": {"$gt": 0}}},), + pipeline_b=({"$match": {"a": {"$gt": 0}}}, {"$set": {"x": 1}}), + plan_cache_same=True, + query_shape_same=False, + msg=( + "adding $set should not change planCacheShapeHash " "but should change queryShapeHash" + ), + ), + PipelineHashCase( + id="unset_presence", + pipeline_a=({"$match": {"a": {"$gt": 0}}},), + pipeline_b=({"$match": {"a": {"$gt": 0}}}, {"$unset": "a"}), + plan_cache_same=True, + query_shape_same=False, + msg=( + "adding $unset should not change planCacheShapeHash " "but should change queryShapeHash" + ), + ), + PipelineHashCase( + id="sample_presence", + pipeline_a=({"$match": {"a": {"$gt": 0}}},), + pipeline_b=({"$match": {"a": {"$gt": 0}}}, {"$sample": {"size": 3}}), + plan_cache_same=True, + query_shape_same=False, + msg=( + "adding $sample should not change planCacheShapeHash " + "but should change queryShapeHash" + ), + ), + PipelineHashCase( + id="out_presence", + pipeline_a=({"$match": {"a": {"$gt": 0}}},), + pipeline_b=({"$match": {"a": {"$gt": 0}}}, {"$out": "probe_out"}), + plan_cache_same=True, + query_shape_same=False, + msg=( + "adding $out should not change planCacheShapeHash " "but should change queryShapeHash" + ), + ), + PipelineHashCase( + id="merge_presence", + pipeline_a=({"$match": {"a": {"$gt": 0}}},), + pipeline_b=({"$match": {"a": {"$gt": 0}}}, {"$merge": {"into": "probe_merged"}}), + plan_cache_same=True, + query_shape_same=False, + msg=( + "adding $merge should not change planCacheShapeHash " "but should change queryShapeHash" + ), + ), + PipelineHashCase( + id="facet_presence", + pipeline_a=({"$match": {"a": {"$gt": 0}}},), + pipeline_b=( + {"$match": {"a": {"$gt": 0}}}, + {"$facet": {"counts": [{"$count": "n"}]}}, + ), + plan_cache_same=True, + query_shape_same=False, + msg=( + "adding $facet should not change planCacheShapeHash " "but should change queryShapeHash" + ), + ), + PipelineHashCase( + id="unionWith_presence", + pipeline_a=({"$match": {"a": {"$gt": 0}}},), + pipeline_b=( + {"$match": {"a": {"$gt": 0}}}, + {"$unionWith": {"coll": "other"}}, + ), + plan_cache_same=True, + query_shape_same=False, + msg=( + "adding $unionWith should not change planCacheShapeHash " + "but should change queryShapeHash" + ), + ), + PipelineHashCase( + id="redact_presence", + pipeline_a=({"$match": {"a": {"$gt": 0}}},), + pipeline_b=({"$match": {"a": {"$gt": 0}}}, {"$redact": "$$KEEP"}), + plan_cache_same=True, + query_shape_same=False, + msg=( + "adding $redact should not change planCacheShapeHash " + "but should change queryShapeHash" + ), + ), + PipelineHashCase( + id="fill_presence", + pipeline_a=({"$match": {"a": {"$gt": 0}}},), + pipeline_b=( + {"$match": {"a": {"$gt": 0}}}, + {"$fill": {"output": {"b": {"method": "locf"}}}}, + ), + plan_cache_same=True, + query_shape_same=False, + msg=( + "adding $fill should not change planCacheShapeHash " "but should change queryShapeHash" + ), + ), +] + + +@pytest.mark.parametrize("test", pytest_params(PIPELINE_DIVERGENCE_TESTS)) +def test_pipeline_plancacheshapehash_stage_sensitivity(collection, test): + """planCacheShapeHash considers filter/sort/projection and certain pipeline stages.""" + collection.insert_many( + [{"_id": i, "a": i % 5, "b": i % 3, "arr": [i, i + 1], "sub": {"x": i}} for i in range(20)] + ) + hash_a = _get_plan_cache_shape_hash(collection, list(test.pipeline_a)) + hash_b = _get_plan_cache_shape_hash(collection, list(test.pipeline_b)) + check = Eq(hash_b) if test.plan_cache_same else Ne(hash_b) + assertProperties( + {"planCacheShapeHash": hash_a}, + {"planCacheShapeHash": check}, + msg=test.msg, + raw_res=True, + ) + + +@pytest.mark.parametrize("test", pytest_params(PIPELINE_DIVERGENCE_TESTS)) +def test_pipeline_queryshapehash_considers_all_stages(collection, test): + """queryShapeHash considers the full pipeline including all stages.""" + collection.insert_many( + [{"_id": i, "a": i % 5, "b": i % 3, "arr": [i, i + 1], "sub": {"x": i}} for i in range(20)] + ) + hash_a = _get_query_shape_hash(collection, list(test.pipeline_a)) + hash_b = _get_query_shape_hash(collection, list(test.pipeline_b)) + check = Eq(hash_b) if test.query_shape_same else Ne(hash_b) + assertProperties( + {"queryShapeHash": hash_a}, + {"queryShapeHash": check}, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_queryhash_shape.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_queryhash_shape.py new file mode 100644 index 000000000..0de460033 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_queryhash_shape.py @@ -0,0 +1,577 @@ +"""Tests for explain queryPlanner.queryHash shape equivalence. + +Parametrized over (queryA, queryB, same_shape) triples. For each pair the test +runs explain on both queries and asserts that their queryPlanner.queryHash +values either match (same_shape=True) or differ (same_shape=False). +""" + +from dataclasses import dataclass +from typing import Any, Optional + +import pytest + +from documentdb_tests.framework.assertions import assertProperties +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, Ne +from documentdb_tests.framework.test_case import BaseTestCase + +pytestmark = pytest.mark.admin + + +def get_query_hash( + collection, + find_filter: dict, + sort: Optional[dict] = None, + projection: Optional[dict] = None, + collation: Optional[dict] = None, +) -> str: + """Return queryPlanner.planCacheShapeHash from explain for a find filter.""" + cmd: dict[str, Any] = {"find": collection.name, "filter": find_filter} + if sort is not None: + cmd["sort"] = sort + if projection is not None: + cmd["projection"] = projection + if collation is not None: + cmd["collation"] = collation + result = execute_command(collection, {"explain": cmd, "verbosity": "queryPlanner"}) + if isinstance(result, Exception): + raise RuntimeError(f"explain command failed: {result}") + query_planner = result.get("queryPlanner", {}) + if "planCacheShapeHash" not in query_planner: + raise RuntimeError(f"explain did not return queryPlanner.planCacheShapeHash; got: {result}") + return str(query_planner["planCacheShapeHash"]) + + +def get_query_shape_hash( + collection, + find_filter: dict, + sort: Optional[dict] = None, + projection: Optional[dict] = None, + collation: Optional[dict] = None, +) -> str: + """Return the top-level queryShapeHash from explain for a find filter.""" + cmd: dict[str, Any] = {"find": collection.name, "filter": find_filter} + if sort is not None: + cmd["sort"] = sort + if projection is not None: + cmd["projection"] = projection + if collation is not None: + cmd["collation"] = collation + result = execute_command(collection, {"explain": cmd, "verbosity": "queryPlanner"}) + if isinstance(result, Exception): + raise RuntimeError(f"explain command failed: {result}") + if "queryShapeHash" not in result: + raise RuntimeError(f"explain did not return queryShapeHash; got: {result}") + return str(result["queryShapeHash"]) + + +@dataclass(frozen=True) +class QueryHashCase(BaseTestCase): + """Test case comparing the queryHash of two find queries. + + query_shape_same overrides the expected same/different verdict specifically + for queryShapeHash when it differs from planCacheShapeHash behaviour. + Leave as None to inherit the planCacheShapeHash expectation. + """ + + filter_a: Optional[dict] = None + filter_b: Optional[dict] = None + sort_a: Optional[dict] = None + sort_b: Optional[dict] = None + projection_a: Optional[dict] = None + projection_b: Optional[dict] = None + collation_a: Optional[dict] = None + collation_b: Optional[dict] = None + query_shape_same: Optional[bool] = None + + +SAME_SHAPE_TESTS: list[QueryHashCase] = [ + QueryHashCase( + id="literal_value_scalar", + filter_a={"a": {"$eq": 1}}, + filter_b={"a": {"$eq": 3}}, + msg="different scalar values with same operator should share a query shape", + ), + QueryHashCase( + id="literal_value_array", + filter_a={"a": {"$in": [1, 2, 3]}}, + filter_b={"a": {"$in": [4, 5, 6]}}, + msg="$in with same array length but different values should share a query shape", + ), + QueryHashCase( + id="literal_value_document", + filter_a={"a": {"$gt": 1}}, + filter_b={"a": {"$gt": 4}}, + msg="$gt with different operands should share a query shape", + ), + QueryHashCase( + id="implicit_explicit_eq", + filter_a={"a": 1}, + filter_b={"a": {"$eq": 1}}, + msg="implicit equality and explicit $eq should share a query shape", + ), + QueryHashCase( + id="implicit_explicit_and", + filter_a={"a": 1, "b": 2}, + filter_b={"$and": [{"a": 1}, {"b": 2}]}, + msg="implicit multi-field filter and explicit $and should share a query shape", + ), + QueryHashCase( + id="field_clause_order", + filter_a={"a": 1, "b": 2}, + filter_b={"b": 2, "a": 1}, + query_shape_same=False, + msg="reversed top-level field order should share a query shape", + ), + QueryHashCase( + id="and_clause_order", + filter_a={"$and": [{"a": 1}, {"b": 2}]}, + filter_b={"$and": [{"b": 2}, {"a": 1}]}, + query_shape_same=False, + msg="reversed $and clause order should share a query shape", + ), + QueryHashCase( + id="comparison_operand_variation_gt", + filter_a={"a": {"$gt": 0}}, + filter_b={"a": {"$gt": 10}}, + msg="$gt with different operands should share a query shape", + ), + QueryHashCase( + id="comparison_operand_variation_lte", + filter_a={"a": {"$lte": 2}}, + filter_b={"a": {"$lte": 4}}, + msg="$lte with different operands should share a query shape", + ), + QueryHashCase( + id="in_value_variation", + filter_a={"a": {"$in": [1, 2]}}, + filter_b={"a": {"$in": [3, 4]}}, + msg="$in with same length but different values should share a query shape", + ), + QueryHashCase( + id="nin_value_variation", + filter_a={"a": {"$nin": [1, 2]}}, + filter_b={"a": {"$nin": [3, 4]}}, + msg="$nin with same length but different values should share a query shape", + ), + QueryHashCase( + id="all_value_variation", + filter_a={"arr": {"$all": [1, 2]}}, + filter_b={"arr": {"$all": [3, 4]}}, + msg="$all with same length but different values should share a query shape", + ), + QueryHashCase( + id="projection_field_order", + filter_a={"a": 1}, + filter_b={"a": 1}, + query_shape_same=False, + projection_a={"a": 1, "b": 1}, + projection_b={"b": 1, "a": 1}, + msg="same projection fields in different order should share a query shape", + ), + QueryHashCase( + id="not_operand_variation", + filter_a={"a": {"$not": {"$gt": 1}}}, + filter_b={"a": {"$not": {"$gt": 3}}}, + msg="$not with different inner operands should share a query shape", + ), + QueryHashCase( + id="mod_argument_variation", + filter_a={"a": {"$mod": [2, 0]}}, + filter_b={"a": {"$mod": [3, 1]}}, + msg="$mod with different divisor/remainder should share a query shape", + ), + QueryHashCase( + id="size_argument_variation", + filter_a={"arr": {"$size": 2}}, + filter_b={"arr": {"$size": 3}}, + msg="$size with different values should share a query shape", + ), + QueryHashCase( + id="elemMatch_inner_operand_variation", + filter_a={"arr": {"$elemMatch": {"$gt": 0}}}, + filter_b={"arr": {"$elemMatch": {"$gt": 5}}}, + msg="$elemMatch with different inner operands should share a query shape", + ), + QueryHashCase( + id="expr_arithmetic_operand_variation", + filter_a={"$expr": {"$gt": ["$a", 1]}}, + filter_b={"$expr": {"$gt": ["$a", 3]}}, + msg="$expr with different literal operands should share a query shape", + ), + QueryHashCase( + id="or_value_variation", + filter_a={"$or": [{"a": 1}, {"b": 2}]}, + filter_b={"$or": [{"a": 3}, {"b": 4}]}, + msg="$or with different operand values should share a query shape", + ), + QueryHashCase( + id="nor_value_variation", + filter_a={"$nor": [{"a": 1}, {"b": 2}]}, + filter_b={"$nor": [{"a": 3}, {"b": 4}]}, + msg="$nor with different operand values should share a query shape", + ), + QueryHashCase( + id="empty_filter", + filter_a={}, + filter_b={}, + msg="two empty filters should share a query shape", + ), + QueryHashCase( + id="dot_notation_value_variation", + filter_a={"a.b": 1}, + filter_b={"a.b": 2}, + msg="dot-notation field path with different values should share a query shape", + ), + QueryHashCase( + id="comparison_operand_variation_gte", + filter_a={"a": {"$gte": 1}}, + filter_b={"a": {"$gte": 4}}, + msg="$gte with different operands should share a query shape", + ), + QueryHashCase( + id="comparison_operand_variation_lt", + filter_a={"a": {"$lt": 3}}, + filter_b={"a": {"$lt": 7}}, + msg="$lt with different operands should share a query shape", + ), + QueryHashCase( + id="comparison_operand_variation_ne", + filter_a={"a": {"$ne": 1}}, + filter_b={"a": {"$ne": 4}}, + msg="$ne with different operands should share a query shape", + ), + QueryHashCase( + id="in_element_order_variation", + filter_a={"a": {"$in": [1, 2, 3]}}, + filter_b={"a": {"$in": [3, 1, 2]}}, + msg="$in with same elements in different order should share a query shape", + ), + QueryHashCase( + id="sort_absent_vs_no_sort", + filter_a={"a": 1}, + filter_b={"a": 2}, + msg="same filter structure with no sort and different values should share a query shape", + ), + QueryHashCase( + id="collation_strength_variation", + filter_a={"s": "hello"}, + filter_b={"s": "world"}, + collation_a={"locale": "en", "strength": 1}, + collation_b={"locale": "en", "strength": 1}, + msg="same collation locale and strength with different values should share a query shape", + ), + QueryHashCase( + id="regex_pattern_variation", + filter_a={"s": {"$regex": "^1"}}, + filter_b={"s": {"$regex": "^2"}}, + msg="different $regex patterns are treated as literals and share a query shape", + ), + QueryHashCase( + id="expr_operator_variation", + filter_a={"$expr": {"$gt": ["$a", "$b"]}}, + filter_b={"$expr": {"$lt": ["$a", "$b"]}}, + query_shape_same=False, + msg="$expr operators are not part of the shape; different operators share a query shape", + ), + QueryHashCase( + id="type_argument_variation", + filter_a={"a": {"$type": "int"}}, + filter_b={"a": {"$type": "string"}}, + query_shape_same=False, + msg="$type argument is treated as a literal and shares a query shape", + ), + QueryHashCase( + id="type_array_vs_string", + filter_a={"a": {"$type": ["int"]}}, + filter_b={"a": {"$type": "int"}}, + msg="$type array form vs string form shares a query shape", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(SAME_SHAPE_TESTS)) +def test_explain_queryhash_same_shape(collection, test): + """Test that structurally equivalent queries produce the same queryPlanner.queryHash.""" + collection.insert_many( + [{"_id": i, "a": i % 5, "b": i % 3, "arr": [i, i + 1], "s": str(i)} for i in range(20)] + ) + hash_a = get_query_hash( + collection, test.filter_a, test.sort_a, test.projection_a, test.collation_a + ) + hash_b = get_query_hash( + collection, test.filter_b, test.sort_b, test.projection_b, test.collation_b + ) + assertProperties( + {"planCacheShapeHash": hash_a}, + {"planCacheShapeHash": Eq(hash_b)}, + msg=test.msg, + raw_res=True, + ) + + +DIFF_SHAPE_TESTS: list[QueryHashCase] = [ + QueryHashCase( + id="different_field_names", + filter_a={"a": 1}, + filter_b={"b": 1}, + msg="queries on different fields should have different query shapes", + ), + QueryHashCase( + id="comparison_operator_variation", + filter_a={"a": {"$gt": 1}}, + filter_b={"a": {"$lt": 1}}, + msg="different comparison operators on the same field should have different query shapes", + ), + QueryHashCase( + id="in_vs_nin", + filter_a={"a": {"$in": [1, 2]}}, + filter_b={"a": {"$nin": [1, 2]}}, + msg="$in vs $nin should have different query shapes", + ), + QueryHashCase( + id="and_vs_or", + filter_a={"$and": [{"a": 1}, {"b": 2}]}, + filter_b={"$or": [{"a": 1}, {"b": 2}]}, + msg="$and vs $or should have different query shapes", + ), + QueryHashCase( + id="or_vs_nor", + filter_a={"$or": [{"a": 1}, {"b": 2}]}, + filter_b={"$nor": [{"a": 1}, {"b": 2}]}, + msg="$or vs $nor should have different query shapes", + ), + QueryHashCase( + id="and_clause_count", + filter_a={"$and": [{"a": 1}]}, + filter_b={"$and": [{"a": 1}, {"b": 2}]}, + msg="$and with different clause counts should have different query shapes", + ), + QueryHashCase( + id="exists_true_vs_false", + filter_a={"a": {"$exists": True}}, + filter_b={"a": {"$exists": False}}, + msg="$exists true vs false should have different query shapes", + ), + QueryHashCase( + id="in_length_variation", + filter_a={"a": {"$in": [1]}}, + filter_b={"a": {"$in": [1, 2]}}, + query_shape_same=True, + msg="$in with different array lengths should have different query shapes", + ), + QueryHashCase( + id="not_inner_operator_variation", + filter_a={"a": {"$not": {"$gt": 1}}}, + filter_b={"a": {"$not": {"$lt": 1}}}, + msg="$not with different inner operators should have different query shapes", + ), + QueryHashCase( + id="sort_field_variation", + filter_a={"a": {"$gt": 0}}, + filter_b={"a": {"$gt": 0}}, + sort_a={"a": 1}, + sort_b={"b": 1}, + msg="same filter sorted on different fields should have different query shapes", + ), + QueryHashCase( + id="projection_field_set", + filter_a={"a": 1}, + filter_b={"a": 1}, + projection_a={"a": 1}, + projection_b={"b": 1}, + msg="different projection field sets should have different query shapes", + ), + QueryHashCase( + id="collation_locale_variation", + filter_a={"s": "hello"}, + filter_b={"s": "hello"}, + collation_a={"locale": "en"}, + collation_b={"locale": "fr"}, + msg="different collation locales should have different query shapes", + ), + QueryHashCase( + id="elemMatch_inner_operator_variation", + filter_a={"arr": {"$elemMatch": {"$gt": 0}}}, + filter_b={"arr": {"$elemMatch": {"$lt": 0}}}, + msg="$elemMatch with different inner operators should have different query shapes", + ), + QueryHashCase( + id="jsonSchema_field_variation", + filter_a={"$jsonSchema": {"required": ["a"]}}, + filter_b={"$jsonSchema": {"required": ["b"]}}, + msg="$jsonSchema requiring different fields should have different query shapes", + ), + QueryHashCase( + id="sort_direction_variation", + filter_a={"a": {"$gt": 0}}, + filter_b={"a": {"$gt": 0}}, + sort_a={"a": 1}, + sort_b={"a": -1}, + msg="same filter with different sort directions should have different query shapes", + ), + QueryHashCase( + id="compound_sort_field_order", + filter_a={"a": {"$gt": 0}}, + filter_b={"a": {"$gt": 0}}, + sort_a={"a": 1, "b": 1}, + sort_b={"b": 1, "a": 1}, + msg="compound sort with different field order should have different query shapes", + ), + QueryHashCase( + id="ne_vs_eq", + filter_a={"a": {"$ne": 1}}, + filter_b={"a": {"$eq": 1}}, + msg="$ne vs $eq on the same field should have different query shapes", + ), + QueryHashCase( + id="exists_vs_equality", + filter_a={"a": {"$exists": True}}, + filter_b={"a": 1}, + msg="$exists:true vs equality predicate should have different query shapes", + ), + QueryHashCase( + id="dot_notation_vs_top_level", + filter_a={"a.b": 1}, + filter_b={"a": 1}, + msg="dot-notation path vs top-level field should have different query shapes", + ), + QueryHashCase( + id="or_clause_count", + filter_a={"$or": [{"a": 1}]}, + filter_b={"$or": [{"a": 1}, {"b": 2}]}, + msg="$or with different clause counts should have different query shapes", + ), + QueryHashCase( + id="nor_clause_count", + filter_a={"$nor": [{"a": 1}]}, + filter_b={"$nor": [{"a": 1}, {"b": 2}]}, + msg="$nor with different clause counts should have different query shapes", + ), + QueryHashCase( + id="all_vs_in", + filter_a={"arr": {"$all": [1, 2]}}, + filter_b={"arr": {"$in": [1, 2]}}, + msg="$all vs $in on the same field should have different query shapes", + ), + QueryHashCase( + id="collation_present_vs_absent", + filter_a={"s": "hello"}, + filter_b={"s": "hello"}, + collation_a={"locale": "en"}, + collation_b=None, + msg="collation present vs absent should produce different query shapes", + ), + QueryHashCase( + id="sort_present_vs_absent", + filter_a={"a": 1}, + filter_b={"a": 1}, + sort_a={"a": 1}, + sort_b=None, + msg="query with sort vs same query without sort should have different query shapes", + ), + QueryHashCase( + id="projection_present_vs_absent", + filter_a={"a": 1}, + filter_b={"a": 1}, + projection_a={"a": 1}, + projection_b=None, + msg="projection present vs absent should produce different query shapes", + ), + QueryHashCase( + id="projection_inclusion_vs_exclusion", + filter_a={"a": 1}, + filter_b={"a": 1}, + projection_a={"b": 1}, + projection_b={"b": 0}, + msg="projection inclusion vs exclusion on the same field should differ in query shape", + ), + QueryHashCase( + id="collation_strength_variation", + filter_a={"s": "hello"}, + filter_b={"s": "hello"}, + collation_a={"locale": "en", "strength": 1}, + collation_b={"locale": "en", "strength": 2}, + msg="same locale with different collation strength should have different query shapes", + ), + QueryHashCase( + id="in_length_all", + filter_a={"arr": {"$all": [1]}}, + filter_b={"arr": {"$all": [1, 2]}}, + msg="$all with different array lengths should have different query shapes", + ), + QueryHashCase( + id="jsonSchema_keyword_variation", + filter_a={"$jsonSchema": {"required": ["a"]}}, + filter_b={"$jsonSchema": {"properties": {"a": {"bsonType": "int"}}}}, + msg="$jsonSchema with different keywords should have different query shapes", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(DIFF_SHAPE_TESTS)) +def test_explain_queryhash_different_shape(collection, test): + """Test that structurally distinct queries produce different queryPlanner.queryHash values.""" + collection.insert_many( + [{"_id": i, "a": i % 5, "b": i % 3, "arr": [i, i + 1], "s": str(i)} for i in range(20)] + ) + hash_a = get_query_hash( + collection, test.filter_a, test.sort_a, test.projection_a, test.collation_a + ) + hash_b = get_query_hash( + collection, test.filter_b, test.sort_b, test.projection_b, test.collation_b + ) + assertProperties( + {"planCacheShapeHash": hash_a}, + {"planCacheShapeHash": Ne(hash_b)}, + msg=test.msg, + raw_res=True, + ) + + +@pytest.mark.parametrize("test", pytest_params(SAME_SHAPE_TESTS)) +def test_explain_queryshapehash_same_shape(collection, test): + """Test that structurally equivalent queries produce the same top-level queryShapeHash. + + Cases annotated with query_shape_same=False diverge from planCacheShapeHash + behaviour: queryShapeHash preserves field order, $and clause order, + projection order, $expr operators, and $type arguments. + """ + expected_same = test.query_shape_same if test.query_shape_same is not None else True + collection.insert_many( + [{"_id": i, "a": i % 5, "b": i % 3, "arr": [i, i + 1], "s": str(i)} for i in range(20)] + ) + hash_a = get_query_shape_hash( + collection, test.filter_a, test.sort_a, test.projection_a, test.collation_a + ) + hash_b = get_query_shape_hash( + collection, test.filter_b, test.sort_b, test.projection_b, test.collation_b + ) + check = Eq(hash_b) if expected_same else Ne(hash_b) + assertProperties( + {"queryShapeHash": hash_a}, {"queryShapeHash": check}, msg=test.msg, raw_res=True + ) + + +@pytest.mark.parametrize("test", pytest_params(DIFF_SHAPE_TESTS)) +def test_explain_queryshapehash_different_shape(collection, test): + """Test that structurally distinct queries produce different top-level queryShapeHash values. + + Cases annotated with query_shape_same=True diverge from planCacheShapeHash + behaviour: queryShapeHash treats $in array length as irrelevant. + """ + expected_same = test.query_shape_same if test.query_shape_same is not None else False + collection.insert_many( + [{"_id": i, "a": i % 5, "b": i % 3, "arr": [i, i + 1], "s": str(i)} for i in range(20)] + ) + hash_a = get_query_shape_hash( + collection, test.filter_a, test.sort_a, test.projection_a, test.collation_a + ) + hash_b = get_query_shape_hash( + collection, test.filter_b, test.sort_b, test.projection_b, test.collation_b + ) + check = Eq(hash_b) if expected_same else Ne(hash_b) + assertProperties( + {"queryShapeHash": hash_a}, {"queryShapeHash": check}, msg=test.msg, raw_res=True + ) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_verbosity_and_response.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_verbosity_and_response.py new file mode 100644 index 000000000..fc12d9f84 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_verbosity_and_response.py @@ -0,0 +1,243 @@ +"""Tests for explain verbosity modes and response structure. + +Covers the response sections produced by each verbosity mode (queryPlanner, +executionStats, allPlansExecution) and the behavioral differences between modes +(query execution and rejected-plan statistics). +""" + +import pytest +from pymongo import IndexModel + +from documentdb_tests.compatibility.tests.core.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.property_checks import Exists, IsType, NotExists + +pytestmark = pytest.mark.admin + + +RESPONSE_FIELD_TESTS: list[CommandTestCase] = [ + CommandTestCase( + id="queryPlanner_root", + docs=[{"_id": i, "a": i % 5} for i in range(20)], + command=lambda ctx: { + "explain": {"find": ctx.collection, "filter": {"a": 1}}, + "verbosity": "queryPlanner", + }, + expected={"queryPlanner": Exists()}, + msg="queryPlanner should contain queryPlanner", + ), + CommandTestCase( + id="queryPlanner_parsedQuery", + docs=[{"_id": i, "a": i % 5} for i in range(20)], + command=lambda ctx: { + "explain": {"find": ctx.collection, "filter": {"a": 1}}, + "verbosity": "queryPlanner", + }, + expected={"queryPlanner.parsedQuery": Exists()}, + msg="queryPlanner should contain queryPlanner.parsedQuery", + ), + CommandTestCase( + id="queryPlanner_winningPlan", + docs=[{"_id": i, "a": i % 5} for i in range(20)], + command=lambda ctx: { + "explain": {"find": ctx.collection, "filter": {"a": 1}}, + "verbosity": "queryPlanner", + }, + expected={"queryPlanner.winningPlan": Exists()}, + msg="queryPlanner should contain queryPlanner.winningPlan", + ), + CommandTestCase( + id="queryPlanner_namespace", + docs=[{"_id": i, "a": i % 5} for i in range(20)], + command=lambda ctx: { + "explain": {"find": ctx.collection, "filter": {"a": 1}}, + "verbosity": "queryPlanner", + }, + expected={"queryPlanner.namespace": IsType("string")}, + msg="queryPlanner should contain queryPlanner.namespace", + ), + CommandTestCase( + id="executionStats_root", + docs=[{"_id": i, "a": i % 5} for i in range(20)], + command=lambda ctx: { + "explain": {"find": ctx.collection, "filter": {"a": 1}}, + "verbosity": "executionStats", + }, + expected={"executionStats": Exists()}, + msg="executionStats should contain executionStats", + ), + CommandTestCase( + id="executionStats_executionSuccess", + docs=[{"_id": i, "a": i % 5} for i in range(20)], + command=lambda ctx: { + "explain": {"find": ctx.collection, "filter": {"a": 1}}, + "verbosity": "executionStats", + }, + expected={"executionStats.executionSuccess": IsType("bool")}, + msg="executionStats should contain executionStats.executionSuccess", + ), + CommandTestCase( + id="executionStats_nReturned", + docs=[{"_id": i, "a": i % 5} for i in range(20)], + command=lambda ctx: { + "explain": {"find": ctx.collection, "filter": {"a": 1}}, + "verbosity": "executionStats", + }, + expected={"executionStats.nReturned": IsType("int")}, + msg="executionStats should contain executionStats.nReturned", + ), + CommandTestCase( + id="executionStats_executionTimeMillis", + docs=[{"_id": i, "a": i % 5} for i in range(20)], + command=lambda ctx: { + "explain": {"find": ctx.collection, "filter": {"a": 1}}, + "verbosity": "executionStats", + }, + expected={"executionStats.executionTimeMillis": Exists()}, + msg="executionStats should contain executionStats.executionTimeMillis", + ), + CommandTestCase( + id="executionStats_totalKeysExamined", + docs=[{"_id": i, "a": i % 5} for i in range(20)], + command=lambda ctx: { + "explain": {"find": ctx.collection, "filter": {"a": 1}}, + "verbosity": "executionStats", + }, + expected={"executionStats.totalKeysExamined": IsType("int")}, + msg="executionStats should contain executionStats.totalKeysExamined", + ), + CommandTestCase( + id="executionStats_totalDocsExamined", + docs=[{"_id": i, "a": i % 5} for i in range(20)], + command=lambda ctx: { + "explain": {"find": ctx.collection, "filter": {"a": 1}}, + "verbosity": "executionStats", + }, + expected={"executionStats.totalDocsExamined": IsType("int")}, + msg="executionStats should contain executionStats.totalDocsExamined", + ), + CommandTestCase( + id="allPlans_queryPlanner", + docs=[{"_id": i, "a": i % 5} for i in range(20)], + command=lambda ctx: { + "explain": {"find": ctx.collection, "filter": {"a": 1}}, + "verbosity": "allPlansExecution", + }, + expected={"queryPlanner": Exists()}, + msg="allPlansExecution should contain queryPlanner", + ), + CommandTestCase( + id="allPlans_executionStats", + docs=[{"_id": i, "a": i % 5} for i in range(20)], + command=lambda ctx: { + "explain": {"find": ctx.collection, "filter": {"a": 1}}, + "verbosity": "allPlansExecution", + }, + expected={"executionStats": Exists()}, + msg="allPlansExecution should contain executionStats", + ), + CommandTestCase( + id="allPlans_rejectedPlans", + docs=[{"_id": i, "a": i % 5} for i in range(20)], + command=lambda ctx: { + "explain": {"find": ctx.collection, "filter": {"a": 1}}, + "verbosity": "allPlansExecution", + }, + expected={"queryPlanner.rejectedPlans": IsType("array")}, + msg="allPlansExecution should contain queryPlanner.rejectedPlans", + ), + # Documentation states explain cannot be run with $out at + # executionStats verbosity, but observed behaviour is that the aggregate + # explain succeeds: planner/execution stats are nested under the $cursor + # stage and the $out stage is reported in the plan, while its write is + # silently dropped (not executed), so the output collection is never created. + CommandTestCase( + id="aggregate_out_execution_stats_silently_drops_out", + command=lambda ctx: { + "explain": { + "aggregate": ctx.collection, + "pipeline": [{"$out": f"{ctx.collection}_out"}], + "cursor": {}, + }, + "verbosity": "executionStats", + }, + expected={ + "stages.0.$cursor.queryPlanner": Exists(), + "stages.0.$cursor.executionStats": Exists(), + "stages.1.$out": Exists(), + }, + msg=( + "explain with $out at executionStats verbosity should succeed;" + " $out is silently dropped" + ), + ), +] + + +@pytest.mark.parametrize("test", pytest_params(RESPONSE_FIELD_TESTS)) +def test_explain_response_contains_field(collection, test): + """Test each verbosity-mode response contains the expected response field.""" + collection = test.prepare(collection.database, 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, + ) + + +MODE_BEHAVIOR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + id="queryPlanner_omits_executionStats", + docs=[{"_id": i, "a": i % 5} for i in range(20)], + command=lambda ctx: { + "explain": {"find": ctx.collection, "filter": {"a": 1}}, + "verbosity": "queryPlanner", + }, + expected={"executionStats": NotExists()}, + msg="queryPlanner must not execute", + ), + CommandTestCase( + id="executionStats_excludes_rejected_plan_stats", + docs=[{"_id": i, "a": i % 5, "b": i % 3} for i in range(60)], + indexes=[IndexModel([("a", 1)]), IndexModel([("a", 1), ("b", 1)])], + command=lambda ctx: { + "explain": {"find": ctx.collection, "filter": {"a": 2, "b": 1}}, + "verbosity": "executionStats", + }, + expected={"executionStats.allPlansExecution": NotExists()}, + msg="executionStats mode should not include rejected-plan execution", + ), + CommandTestCase( + id="default_verbosity_matches_allPlansExecution", + docs=[{"_id": i, "a": i % 5} for i in range(20)], + command=lambda ctx: { + "explain": {"find": ctx.collection, "filter": {"a": 1}}, + }, + expected={"queryPlanner": Exists(), "executionStats": Exists()}, + msg="default verbosity should include both queryPlanner and executionStats", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(MODE_BEHAVIOR_TESTS)) +def test_explain_verbosity_mode_behavior(collection, test): + """Test verbosity modes include/exclude execution sections as documented.""" + collection = test.prepare(collection.database, 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/compatibility/tests/system/diagnostic/commands/explain/test_explain_write_operations.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_write_operations.py new file mode 100644 index 000000000..2a44e7b8b --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_write_operations.py @@ -0,0 +1,145 @@ +"""Tests for explain on write operations. + +Covers that explain does not modify data for update / delete / findAndModify, +and that it reports "would" statistics (nWouldModify, nWouldDelete) and the +DELETE execution stage. +""" + +import pytest +from pymongo import IndexModel + +from documentdb_tests.compatibility.tests.core.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.property_checks import Eq + +pytestmark = pytest.mark.admin + + +def test_explain_update_does_not_modify_documents(collection): + """Test explain on an update does not apply the modification.""" + collection.insert_one({"_id": 1, "a": 1, "b": 1}) + execute_command( + collection, + { + "explain": { + "update": collection.name, + "updates": [{"q": {"a": 1}, "u": {"$set": {"b": 999}}}], + } + }, + ) + after = execute_command(collection, {"find": collection.name, "filter": {"_id": 1}}) + assertSuccess( + after, [{"_id": 1, "a": 1, "b": 1}], msg="explain update must not modify documents" + ) + + +def test_explain_delete_does_not_remove_documents(collection): + """Test explain on a delete does not remove any documents.""" + collection.insert_many([{"_id": i} for i in range(5)]) + execute_command( + collection, {"explain": {"delete": collection.name, "deletes": [{"q": {}, "limit": 0}]}} + ) + after = execute_command(collection, {"find": collection.name, "filter": {}}) + assertSuccess( + after, + [{"_id": i} for i in range(5)], + msg="explain delete must not remove documents", + ignore_doc_order=True, + ) + + +def test_explain_findAndModify_does_not_modify_documents(collection): + """Test explain on a findAndModify does not apply the modification.""" + collection.insert_one({"_id": 1, "a": 1}) + execute_command( + collection, + { + "explain": { + "findAndModify": collection.name, + "query": {"a": 1}, + "update": {"$set": {"a": 2}}, + } + }, + ) + after = execute_command(collection, {"find": collection.name, "filter": {"_id": 1}}) + assertSuccess( + after, [{"_id": 1, "a": 1}], msg="explain findAndModify must not modify documents" + ) + + +WOULD_STATS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + id="update_would_modify_count", + docs=[{"_id": i, "a": i % 2} for i in range(6)], + command=lambda ctx: { + "explain": { + "update": ctx.collection, + "updates": [{"q": {"a": 1}, "u": {"$set": {"b": 9}}, "multi": True}], + }, + "verbosity": "executionStats", + }, + expected={"executionStats.executionStages.nWouldModify": Eq(3)}, + msg="explain update should report nWouldModify", + ), + CommandTestCase( + id="delete_empty_collection_reports_zero", + docs=[], + command=lambda ctx: { + "explain": {"delete": ctx.collection, "deletes": [{"q": {}, "limit": 0}]}, + "verbosity": "executionStats", + }, + expected={"executionStats.executionStages.nWouldDelete": Eq(0)}, + msg="empty collection should report nWouldDelete 0", + ), + CommandTestCase( + id="delete_empty_indexed_collection_reports_zero", + docs=[], + indexes=[IndexModel([("a", 1)], name="a_1")], + command=lambda ctx: { + "explain": {"delete": ctx.collection, "deletes": [{"q": {"a": 1}, "limit": 0}]}, + "verbosity": "executionStats", + }, + expected={"executionStats.executionStages.nWouldDelete": Eq(0)}, + msg="empty indexed collection should report nWouldDelete 0", + ), + CommandTestCase( + id="delete_reports_matching_count", + docs=[{"_id": i, "a": 1 if i < 3 else 2} for i in range(5)], + command=lambda ctx: { + "explain": {"delete": ctx.collection, "deletes": [{"q": {"a": 1}, "limit": 0}]}, + "verbosity": "executionStats", + }, + expected={"executionStats.executionStages.nWouldDelete": Eq(3)}, + msg="should report nWouldDelete matching the query", + ), + CommandTestCase( + id="delete_shows_delete_stage", + docs=[{"_id": i, "a": 1} for i in range(3)], + command=lambda ctx: { + "explain": {"delete": ctx.collection, "deletes": [{"q": {"a": 1}, "limit": 1}]}, + "verbosity": "executionStats", + }, + expected={"executionStats.executionStages.stage": Eq("DELETE")}, + msg="explain single delete should show a DELETE stage", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(WOULD_STATS_TESTS)) +def test_explain_write_would_stats(collection, test): + """Test explain on writes reports would-modify/would-delete stats and stages.""" + collection = test.prepare(collection.database, 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, + ) From 409cfcc1d74c140457651b45104c000edadac4fd Mon Sep 17 00:00:00 2001 From: Daniel Frankcom Date: Mon, 13 Jul 2026 11:37:34 -0700 Subject: [PATCH 19/35] Add search stage tests (#645) Signed-off-by: Daniel Frankcom Signed-off-by: RyanGarfinkel <113050972+RyanGarfinkel@users.noreply.github.com> --- .../test_cloneCollectionAsCapped_wc.py | 5 +- .../test_convertToCapped_wc.py | 6 +- .../test_dropDatabase_wc_wtimeout.py | 40 +- .../test_renameCollection_wc_wtimeout.py | 42 +- .../core/operator/stages/search/__init__.py | 0 .../core/operator/stages/search/conftest.py | 31 + .../stages/search/test_search_autocomplete.py | 493 +++++++++++++ .../stages/search/test_search_compound.py | 463 ++++++++++++ .../stages/search/test_search_count.py | 296 ++++++++ .../search/test_search_embeddedDocument.py | 130 ++++ .../stages/search/test_search_equals.py | 409 +++++++++++ .../stages/search/test_search_exists.py | 177 +++++ .../stages/search/test_search_facet.py | 94 +++ .../operator/stages/search/test_search_geo.py | 606 ++++++++++++++++ .../stages/search/test_search_highlight.py | 253 +++++++ .../operator/stages/search/test_search_in.py | 485 +++++++++++++ .../stages/search/test_search_moreLikeThis.py | 73 ++ .../stages/search/test_search_near.py | 443 ++++++++++++ .../search/test_search_option_errors.py | 270 +++++++ .../stages/search/test_search_options.py | 446 ++++++++++++ .../stages/search/test_search_pagination.py | 184 +++++ .../stages/search/test_search_phrase.py | 224 ++++++ .../stages/search/test_search_queryString.py | 124 ++++ .../stages/search/test_search_range.py | 683 ++++++++++++++++++ .../stages/search/test_search_regex.py | 319 ++++++++ .../stages/search/test_search_return_scope.py | 125 ++++ .../stages/search/test_search_score.py | 306 ++++++++ .../stages/search/test_search_span.py | 544 ++++++++++++++ .../stages/search/test_search_spec_errors.py | 164 +++++ .../stages/search/test_search_stage_basics.py | 170 +++++ .../search/test_search_stored_source.py | 194 +++++ .../search/test_search_text_analysis.py | 394 ++++++++++ .../stages/search/test_search_text_basics.py | 231 ++++++ .../stages/search/test_search_text_errors.py | 409 +++++++++++ .../search/test_search_text_operator.py | 420 +++++++++++ .../test_search_text_query_operators.py | 359 +++++++++ .../test_search_unsupported_operators.py | 279 +++++++ .../stages/search/test_search_wildcard.py | 457 ++++++++++++ .../stages/search/test_smoke_search.py | 3 +- .../operator/stages/search/utils/__init__.py | 0 .../stages/search/utils/search_common.py | 67 ++ .../stages/test_stages_position_search.py | 177 +++++ 42 files changed, 10544 insertions(+), 51 deletions(-) create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/search/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/search/conftest.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_autocomplete.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_compound.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_count.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_embeddedDocument.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_equals.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_exists.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_facet.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_geo.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_highlight.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_in.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_moreLikeThis.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_near.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_option_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_options.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_pagination.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_phrase.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_queryString.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_range.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_regex.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_return_scope.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_score.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_span.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_spec_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_stage_basics.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_stored_source.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_text_analysis.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_text_basics.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_text_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_text_operator.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_text_query_operators.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_unsupported_operators.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_wildcard.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/search/utils/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/search/utils/search_common.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/test_stages_position_search.py diff --git a/documentdb_tests/compatibility/tests/core/collections/commands/cloneCollectionAsCapped/test_cloneCollectionAsCapped_wc.py b/documentdb_tests/compatibility/tests/core/collections/commands/cloneCollectionAsCapped/test_cloneCollectionAsCapped_wc.py index 906514c7e..d3ea05077 100644 --- a/documentdb_tests/compatibility/tests/core/collections/commands/cloneCollectionAsCapped/test_cloneCollectionAsCapped_wc.py +++ b/documentdb_tests/compatibility/tests/core/collections/commands/cloneCollectionAsCapped/test_cloneCollectionAsCapped_wc.py @@ -26,6 +26,7 @@ ) from documentdb_tests.framework.executor import execute_command from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq from documentdb_tests.framework.test_constants import ( DECIMAL128_ZERO, FLOAT_INFINITY, @@ -101,7 +102,7 @@ "size": 100_000, "writeConcern": {"wtimeout": 0}, }, - expected={"ok": 1.0}, + expected={"ok": Eq(1.0)}, msg="wtimeout=0 should succeed", ), CommandTestCase( @@ -113,7 +114,7 @@ "size": 100_000, "writeConcern": {"wtimeout": 1000}, }, - expected={"ok": 1.0}, + expected={"ok": Eq(1.0)}, msg="wtimeout=1000 should succeed", ), ] diff --git a/documentdb_tests/compatibility/tests/core/collections/commands/convertToCapped/test_convertToCapped_wc.py b/documentdb_tests/compatibility/tests/core/collections/commands/convertToCapped/test_convertToCapped_wc.py index 8a073c176..747e0e8ae 100644 --- a/documentdb_tests/compatibility/tests/core/collections/commands/convertToCapped/test_convertToCapped_wc.py +++ b/documentdb_tests/compatibility/tests/core/collections/commands/convertToCapped/test_convertToCapped_wc.py @@ -27,6 +27,7 @@ ) from documentdb_tests.framework.executor import execute_command from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq from documentdb_tests.framework.test_constants import ( DECIMAL128_ZERO, FLOAT_INFINITY, @@ -99,11 +100,8 @@ "size": 100_000, "writeConcern": {"wtimeout": v}, }, - expected={"ok": 1.0}, + expected={"ok": Eq(1.0)}, msg=f"wtimeout={id} should succeed", - # A negative wtimeout is accepted cleanly on a standalone server but a - # replica set reports a writeConcernError, so gate that case. - marks=((pytest.mark.requires(quorum_write_concern=False),) if id == "negative" else ()), ) for id, val in [ ("zero", 0), diff --git a/documentdb_tests/compatibility/tests/core/collections/commands/dropDatabase/test_dropDatabase_wc_wtimeout.py b/documentdb_tests/compatibility/tests/core/collections/commands/dropDatabase/test_dropDatabase_wc_wtimeout.py index 28564de83..7f7cc7f90 100644 --- a/documentdb_tests/compatibility/tests/core/collections/commands/dropDatabase/test_dropDatabase_wc_wtimeout.py +++ b/documentdb_tests/compatibility/tests/core/collections/commands/dropDatabase/test_dropDatabase_wc_wtimeout.py @@ -10,6 +10,7 @@ from documentdb_tests.framework.error_codes import FAILED_TO_PARSE_ERROR from documentdb_tests.framework.executor import execute_command from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq from documentdb_tests.framework.test_constants import ( DECIMAL128_INFINITY, DECIMAL128_NAN, @@ -32,49 +33,49 @@ WRITE_CONCERN_WTIMEOUT_ACCEPTED_TESTS: list[CommandTestCase] = [ CommandTestCase( command={"dropDatabase": 1, "writeConcern": {"wtimeout": True}}, - expected={"ok": 1.0}, + expected={"ok": Eq(1.0)}, msg="wtimeout:bool true should be accepted", id="wtimeout_bool_true", ), CommandTestCase( command={"dropDatabase": 1, "writeConcern": {"wtimeout": False}}, - expected={"ok": 1.0}, + expected={"ok": Eq(1.0)}, msg="wtimeout:bool false should be accepted", id="wtimeout_bool_false", ), CommandTestCase( command={"dropDatabase": 1, "writeConcern": {"wtimeout": 2.5}}, - expected={"ok": 1.0}, + expected={"ok": Eq(1.0)}, msg="wtimeout:double should be accepted", id="wtimeout_double", ), CommandTestCase( command={"dropDatabase": 1, "writeConcern": {"wtimeout": Int64(42)}}, - expected={"ok": 1.0}, + expected={"ok": Eq(1.0)}, msg="wtimeout:Int64 should be accepted", id="wtimeout_int64", ), CommandTestCase( command={"dropDatabase": 1, "writeConcern": {"wtimeout": Decimal128("99")}}, - expected={"ok": 1.0}, + expected={"ok": Eq(1.0)}, msg="wtimeout:Decimal128 should be accepted", id="wtimeout_decimal128", ), CommandTestCase( command={"dropDatabase": 1, "writeConcern": {"wtimeout": 5}}, - expected={"ok": 1.0}, + expected={"ok": Eq(1.0)}, msg="wtimeout:int32 should be accepted", id="wtimeout_int32", ), CommandTestCase( command={"dropDatabase": 1, "writeConcern": {"wtimeout": "hello"}}, - expected={"ok": 1.0}, + expected={"ok": Eq(1.0)}, msg="wtimeout:string should be accepted", id="wtimeout_string", ), CommandTestCase( command={"dropDatabase": 1, "writeConcern": {"wtimeout": None}}, - expected={"ok": 1.0}, + expected={"ok": Eq(1.0)}, msg="wtimeout:null should be treated as omitted and succeed", id="wtimeout_null", ), @@ -86,7 +87,7 @@ WRITE_CONCERN_WTIMEOUT_SPECIAL_VALUES_TESTS: list[CommandTestCase] = [ CommandTestCase( command={"dropDatabase": 1, "writeConcern": {"wtimeout": INT32_MAX}}, - expected={"ok": 1.0}, + expected={"ok": Eq(1.0)}, msg="wtimeout:max int32 should be accepted", id="wtimeout_max_int32", ), @@ -95,58 +96,55 @@ "dropDatabase": 1, "writeConcern": {"wtimeout": INT64_MIN}, }, - expected={"ok": 1.0}, + expected={"ok": Eq(1.0)}, msg="wtimeout:Int64 min should be accepted", - marks=(pytest.mark.requires(quorum_write_concern=False),), id="wtimeout_int64_min", ), CommandTestCase( command={"dropDatabase": 1, "writeConcern": {"wtimeout": FLOAT_NAN}}, - expected={"ok": 1.0}, + expected={"ok": Eq(1.0)}, msg="wtimeout:NaN should be accepted", id="wtimeout_float_nan", ), CommandTestCase( command={"dropDatabase": 1, "writeConcern": {"wtimeout": FLOAT_NEGATIVE_NAN}}, - expected={"ok": 1.0}, + expected={"ok": Eq(1.0)}, msg="wtimeout:negative NaN should be accepted", id="wtimeout_float_neg_nan", ), CommandTestCase( command={"dropDatabase": 1, "writeConcern": {"wtimeout": FLOAT_NEGATIVE_INFINITY}}, - expected={"ok": 1.0}, + expected={"ok": Eq(1.0)}, msg="wtimeout:-Infinity should be accepted", - marks=(pytest.mark.requires(quorum_write_concern=False),), id="wtimeout_float_neg_infinity", ), CommandTestCase( command={"dropDatabase": 1, "writeConcern": {"wtimeout": DECIMAL128_NAN}}, - expected={"ok": 1.0}, + expected={"ok": Eq(1.0)}, msg="wtimeout:Decimal128('NaN') should be accepted", id="wtimeout_decimal128_nan", ), CommandTestCase( command={"dropDatabase": 1, "writeConcern": {"wtimeout": DECIMAL128_NEGATIVE_NAN}}, - expected={"ok": 1.0}, + expected={"ok": Eq(1.0)}, msg="wtimeout:Decimal128 negative NaN should be accepted", id="wtimeout_decimal128_neg_nan", ), CommandTestCase( command={"dropDatabase": 1, "writeConcern": {"wtimeout": DECIMAL128_NEGATIVE_INFINITY}}, - expected={"ok": 1.0}, + expected={"ok": Eq(1.0)}, msg="wtimeout:Decimal128('-Infinity') should be accepted", - marks=(pytest.mark.requires(quorum_write_concern=False),), id="wtimeout_decimal128_neg_infinity", ), CommandTestCase( command={"dropDatabase": 1, "writeConcern": {"wtimeout": DOUBLE_NEGATIVE_ZERO}}, - expected={"ok": 1.0}, + expected={"ok": Eq(1.0)}, msg="wtimeout:float -0.0 should be accepted", id="wtimeout_float_neg_zero", ), CommandTestCase( command={"dropDatabase": 1, "writeConcern": {"wtimeout": DECIMAL128_NEGATIVE_ZERO}}, - expected={"ok": 1.0}, + expected={"ok": Eq(1.0)}, msg="wtimeout:Decimal128 -0 should be accepted", id="wtimeout_decimal128_neg_zero", ), diff --git a/documentdb_tests/compatibility/tests/core/collections/commands/renameCollection/test_renameCollection_wc_wtimeout.py b/documentdb_tests/compatibility/tests/core/collections/commands/renameCollection/test_renameCollection_wc_wtimeout.py index c04139e9b..0043d1db5 100644 --- a/documentdb_tests/compatibility/tests/core/collections/commands/renameCollection/test_renameCollection_wc_wtimeout.py +++ b/documentdb_tests/compatibility/tests/core/collections/commands/renameCollection/test_renameCollection_wc_wtimeout.py @@ -18,6 +18,7 @@ ) from documentdb_tests.framework.executor import execute_admin_command from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq from documentdb_tests.framework.test_constants import ( DECIMAL128_INFINITY, DECIMAL128_NAN, @@ -93,7 +94,7 @@ "to": f"{ctx.database}.{ctx.collection}_dest", "writeConcern": {"wtimeout": 0}, }, - expected={"ok": 1.0}, + expected={"ok": Eq(1.0)}, msg="wtimeout=0 should be accepted", ), CommandTestCase( @@ -104,7 +105,7 @@ "to": f"{ctx.database}.{ctx.collection}_dest", "writeConcern": {"wtimeout": 1000}, }, - expected={"ok": 1.0}, + expected={"ok": Eq(1.0)}, msg="wtimeout=1000 should be accepted", ), CommandTestCase( @@ -115,7 +116,7 @@ "to": f"{ctx.database}.{ctx.collection}_dest", "writeConcern": {"wtimeout": INT32_OVERFLOW - 1}, }, - expected={"ok": 1.0}, + expected={"ok": Eq(1.0)}, msg="wtimeout=INT32_MAX should be accepted", ), CommandTestCase( @@ -126,9 +127,8 @@ "to": f"{ctx.database}.{ctx.collection}_dest", "writeConcern": {"wtimeout": -1}, }, - expected={"ok": 1.0}, + expected={"ok": Eq(1.0)}, msg="wtimeout=-1 (negative) should be accepted", - marks=(pytest.mark.requires(quorum_write_concern=False),), ), CommandTestCase( "wc_wtimeout_neg_infinity", @@ -138,9 +138,8 @@ "to": f"{ctx.database}.{ctx.collection}_dest", "writeConcern": {"wtimeout": FLOAT_NEGATIVE_INFINITY}, }, - expected={"ok": 1.0}, + expected={"ok": Eq(1.0)}, msg="wtimeout=-Infinity should be accepted", - marks=(pytest.mark.requires(quorum_write_concern=False),), ), CommandTestCase( "wc_wtimeout_nan", @@ -150,7 +149,7 @@ "to": f"{ctx.database}.{ctx.collection}_dest", "writeConcern": {"wtimeout": FLOAT_NAN}, }, - expected={"ok": 1.0}, + expected={"ok": Eq(1.0)}, msg="wtimeout=NaN should be accepted", ), CommandTestCase( @@ -161,7 +160,7 @@ "to": f"{ctx.database}.{ctx.collection}_dest", "writeConcern": {"wtimeout": "1000"}, }, - expected={"ok": 1.0}, + expected={"ok": Eq(1.0)}, msg="wtimeout as string should be accepted", ), CommandTestCase( @@ -172,7 +171,7 @@ "to": f"{ctx.database}.{ctx.collection}_dest", "writeConcern": {"wtimeout": True}, }, - expected={"ok": 1.0}, + expected={"ok": Eq(1.0)}, msg="wtimeout as bool should be accepted", ), CommandTestCase( @@ -183,7 +182,7 @@ "to": f"{ctx.database}.{ctx.collection}_dest", "writeConcern": {"wtimeout": None}, }, - expected={"ok": 1.0}, + expected={"ok": Eq(1.0)}, msg="wtimeout=null should be accepted", ), CommandTestCase( @@ -194,7 +193,7 @@ "to": f"{ctx.database}.{ctx.collection}_dest", "writeConcern": {"wtimeout": [1000]}, }, - expected={"ok": 1.0}, + expected={"ok": Eq(1.0)}, msg="wtimeout as array should be accepted", ), CommandTestCase( @@ -205,7 +204,7 @@ "to": f"{ctx.database}.{ctx.collection}_dest", "writeConcern": {"wtimeout": {"ms": 1000}}, }, - expected={"ok": 1.0}, + expected={"ok": Eq(1.0)}, msg="wtimeout as object should be accepted", ), CommandTestCase( @@ -216,7 +215,7 @@ "to": f"{ctx.database}.{ctx.collection}_dest", "writeConcern": {"wtimeout": Int64(1000)}, }, - expected={"ok": 1.0}, + expected={"ok": Eq(1.0)}, msg="wtimeout as Int64 should be accepted", ), CommandTestCase( @@ -227,7 +226,7 @@ "to": f"{ctx.database}.{ctx.collection}_dest", "writeConcern": {"wtimeout": Decimal128("1000")}, }, - expected={"ok": 1.0}, + expected={"ok": Eq(1.0)}, msg="wtimeout as Decimal128 should be accepted", ), CommandTestCase( @@ -238,7 +237,7 @@ "to": f"{ctx.database}.{ctx.collection}_dest", "writeConcern": {"wtimeout": FLOAT_NEGATIVE_NAN}, }, - expected={"ok": 1.0}, + expected={"ok": Eq(1.0)}, msg="wtimeout=negative NaN should be accepted", ), CommandTestCase( @@ -249,7 +248,7 @@ "to": f"{ctx.database}.{ctx.collection}_dest", "writeConcern": {"wtimeout": DECIMAL128_NAN}, }, - expected={"ok": 1.0}, + expected={"ok": Eq(1.0)}, msg="wtimeout=Decimal128('NaN') should be accepted", ), CommandTestCase( @@ -260,7 +259,7 @@ "to": f"{ctx.database}.{ctx.collection}_dest", "writeConcern": {"wtimeout": DECIMAL128_NEGATIVE_NAN}, }, - expected={"ok": 1.0}, + expected={"ok": Eq(1.0)}, msg="wtimeout=Decimal128 negative NaN should be accepted", ), CommandTestCase( @@ -271,9 +270,8 @@ "to": f"{ctx.database}.{ctx.collection}_dest", "writeConcern": {"wtimeout": DECIMAL128_NEGATIVE_INFINITY}, }, - expected={"ok": 1.0}, + expected={"ok": Eq(1.0)}, msg="wtimeout=Decimal128('-Infinity') should be accepted", - marks=(pytest.mark.requires(quorum_write_concern=False),), ), CommandTestCase( "wc_wtimeout_neg_zero", @@ -283,7 +281,7 @@ "to": f"{ctx.database}.{ctx.collection}_dest", "writeConcern": {"wtimeout": DOUBLE_NEGATIVE_ZERO}, }, - expected={"ok": 1.0}, + expected={"ok": Eq(1.0)}, msg="wtimeout=-0.0 should be accepted", ), CommandTestCase( @@ -294,7 +292,7 @@ "to": f"{ctx.database}.{ctx.collection}_dest", "writeConcern": {"wtimeout": DECIMAL128_NEGATIVE_ZERO}, }, - expected={"ok": 1.0}, + expected={"ok": Eq(1.0)}, msg="wtimeout=Decimal128('-0') should be accepted", ), ] diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/search/__init__.py b/documentdb_tests/compatibility/tests/core/operator/stages/search/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/search/conftest.py b/documentdb_tests/compatibility/tests/core/operator/stages/search/conftest.py new file mode 100644 index 000000000..963019c77 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/search/conftest.py @@ -0,0 +1,31 @@ +"""Shared fixtures for $search stage tests. + +The dynamic-mapping ``indexed_collection`` corpus is queried read-only by most +$search test files, so it is built once per package here rather than duplicated +per file. Single-operator corpora (wildcard, equals, in, ...) stay inline in +their own test file, since each is used by exactly one file.""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.stages.search.utils.search_common import ( + FIXTURE_DOCS, + create_dynamic_search_index, +) +from documentdb_tests.framework import fixtures + + +@pytest.fixture(scope="package") +def indexed_collection(engine_client, worker_id): + """A package-scoped collection populated with the fixture docs and a ready + dynamic search index, shared read-only across the matching tests so the + index is built and polled once rather than per test.""" + db_name = fixtures.generate_database_name("stages_search_shared", worker_id) + fixtures.cleanup_database(engine_client, db_name) + db = engine_client[db_name] + coll = db["indexed"] + coll.insert_many(FIXTURE_DOCS) + create_dynamic_search_index(coll) + yield coll + fixtures.cleanup_database(engine_client, db_name) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_autocomplete.py b/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_autocomplete.py new file mode 100644 index 000000000..fe19b334f --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_autocomplete.py @@ -0,0 +1,493 @@ +"""Tests for the $search autocomplete operator.""" + +from __future__ import annotations + +import datetime + +import pytest +from bson import Binary, Code, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.stages.search.utils.search_common import ( + create_search_index, +) +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, +) +from documentdb_tests.framework import fixtures +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + UNKNOWN_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import ( + Contains, + Len, +) +from documentdb_tests.framework.test_constants import ( + DECIMAL128_ONE_AND_HALF, +) + +pytestmark = pytest.mark.requires(search=True) + + +_AUTOCOMPLETE_DOCS = [ + {"_id": 1, "ac": "september"}, # matches sep, sept, and the fuzzy typos + {"_id": 2, "ac": "october"}, # control: shares no probed prefix + {"_id": 3, "ac": "separate"}, # shares the sep prefix but not sept + {"_id": 4, "ac": "quick brown"}, # two tokens for the tokenOrder cases +] + +_AUTOCOMPLETE_INDEX_DEFINITION = { + "mappings": { + "dynamic": False, + "fields": { + "ac": {"type": "autocomplete"}, + }, + } +} + + +@pytest.fixture(scope="module") +def autocomplete_collection(engine_client, worker_id): + """A module-scoped collection with a static search index mapping an + autocomplete-typed field, shared read-only across the autocomplete cases so + the index is built and polled once.""" + db_name = fixtures.generate_database_name("stages_search_autocomplete", worker_id) + fixtures.cleanup_database(engine_client, db_name) + db = engine_client[db_name] + coll = db["autocomplete"] + coll.insert_many(_AUTOCOMPLETE_DOCS) + create_search_index(coll, _AUTOCOMPLETE_INDEX_DEFINITION) + yield coll + fixtures.cleanup_database(engine_client, db_name) + + +# Property [Autocomplete Edge-Gram Prefix Matching]: on an autocomplete-mapped +# path the query matches stored tokens by edge-gram prefix. +SEARCH_AUTOCOMPLETE_PREFIX_TESTS: list[StageTestCase] = [ + StageTestCase( + "autocomplete_prefix_short", + pipeline=[ + {"$search": {"autocomplete": {"path": "ac", "query": "sep"}}}, + ], + expected={"cursor.firstBatch": [Len(2), Contains("_id", 1), Contains("_id", 3)]}, + msg="$search autocomplete should match every stored token sharing the query prefix", + ), + StageTestCase( + "autocomplete_score_boost", + pipeline=[ + { + "$search": { + "autocomplete": { + "path": "ac", + "query": "sep", + "score": {"boost": {"value": 2.0}}, + } + } + }, + ], + expected={"cursor.firstBatch": [Len(2), Contains("_id", 1), Contains("_id", 3)]}, + msg="$search autocomplete should accept a score modifier and still return its matches", + ), + StageTestCase( + "autocomplete_prefix_longer", + pipeline=[ + {"$search": {"autocomplete": {"path": "ac", "query": "sept"}}}, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 1)]}, + msg="$search autocomplete should match only the tokens extending the longer query prefix", + ), +] + +# Property [Autocomplete Fuzzy Matching]: autocomplete accepts a fuzzy.maxEdits of +# 1 or 2 and matches a query within that many edits of a stored token. +SEARCH_AUTOCOMPLETE_FUZZY_TESTS: list[StageTestCase] = [ + StageTestCase( + "autocomplete_fuzzy_max_edits_1", + # "septemer" is one deletion (the "b") away from the stored "september". + pipeline=[ + { + "$search": { + "autocomplete": { + "path": "ac", + "query": "septemer", + "fuzzy": {"maxEdits": 1}, + } + } + }, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 1)]}, + msg="$search autocomplete should match a query within one edit at fuzzy.maxEdits 1", + ), + StageTestCase( + "autocomplete_fuzzy_max_edits_2", + # "septmer" is two deletions (the "e" and the "b") from the stored "september". + pipeline=[ + { + "$search": { + "autocomplete": {"path": "ac", "query": "septmer", "fuzzy": {"maxEdits": 2}} + } + }, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 1)]}, + msg="$search autocomplete should match a query within two edits at fuzzy.maxEdits 2", + ), +] + +# Property [Autocomplete Token Order]: tokenOrder "any" matches the query terms in +# any order while "sequential" requires them in the stored order. +SEARCH_AUTOCOMPLETE_TOKEN_ORDER_TESTS: list[StageTestCase] = [ + StageTestCase( + "autocomplete_token_order_any", + pipeline=[ + { + "$search": { + "autocomplete": {"path": "ac", "query": "brown quick", "tokenOrder": "any"} + } + }, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 4)]}, + msg="$search autocomplete tokenOrder any should match the query terms in any order", + ), + StageTestCase( + "autocomplete_token_order_sequential_in_order", + pipeline=[ + { + "$search": { + "autocomplete": { + "path": "ac", + "query": "quick brown", + "tokenOrder": "sequential", + } + } + }, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 4)]}, + msg="$search autocomplete tokenOrder sequential should match terms in the stored order", + ), + StageTestCase( + "autocomplete_token_order_sequential_reversed", + pipeline=[ + { + "$search": { + "autocomplete": { + "path": "ac", + "query": "brown quick", + "tokenOrder": "sequential", + } + } + }, + ], + expected={"cursor.firstBatch": Len(0)}, + msg="$search autocomplete tokenOrder sequential should not match terms out of order", + ), +] + +# Property [Autocomplete Query Array OR]: autocomplete.query accepts an array of +# strings, matching the union of the documents matched by each element prefix. +SEARCH_AUTOCOMPLETE_QUERY_ARRAY_TESTS: list[StageTestCase] = [ + StageTestCase( + "autocomplete_query_array_or", + pipeline=[ + {"$search": {"autocomplete": {"path": "ac", "query": ["sept", "octo"]}}}, + ], + expected={"cursor.firstBatch": [Len(2), Contains("_id", 1), Contains("_id", 2)]}, + msg="$search autocomplete should match the union of a multi-element query array's prefixes", + ), +] + +# Property [Autocomplete Fuzzy prefixLength And maxExpansions]: autocomplete.fuzzy +# accepts prefixLength and maxExpansions, where prefixLength locks a +# code-point-counted prefix from edits (a typo inside the locked prefix does not +# match while prefixLength 0 still allows the fuzzy match) and maxExpansions is +# accepted across its 1..1000 bound. +SEARCH_AUTOCOMPLETE_FUZZY_PREFIX_EXPANSION_TESTS: list[StageTestCase] = [ + StageTestCase( + "autocomplete_fuzzy_prefix_unlocked", + # "xeptember" is one substitution (x for s) at code point 0 from the stored + # "september"; prefixLength 0 locks nothing so the edit is allowed. + pipeline=[ + { + "$search": { + "autocomplete": { + "path": "ac", + "query": "xeptember", + "fuzzy": {"maxEdits": 1, "prefixLength": 0}, + } + } + }, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 1)]}, + msg="$search autocomplete should allow a fuzzy edit outside the prefix when " + "prefixLength is 0", + ), + StageTestCase( + "autocomplete_fuzzy_prefix_locked", + # prefixLength 2 locks code points 0 and 1, so the substitution at code + # point 0 falls inside the locked prefix and cannot match. + pipeline=[ + { + "$search": { + "autocomplete": { + "path": "ac", + "query": "xeptember", + "fuzzy": {"maxEdits": 1, "prefixLength": 2}, + } + } + }, + ], + expected={"cursor.firstBatch": Len(0)}, + msg="$search autocomplete should lock the prefix from edits so a locked-prefix typo " + "does not match", + ), + *[ + StageTestCase( + f"autocomplete_fuzzy_max_expansions_{label}", + # "septemer" is one deletion from the stored "september", matching only it + # regardless of the maxExpansions bound. + pipeline=[ + { + "$search": { + "autocomplete": { + "path": "ac", + "query": "septemer", + "fuzzy": {"maxEdits": 1, "maxExpansions": val}, + } + } + }, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 1)]}, + msg=f"$search autocomplete should accept fuzzy.maxExpansions at the {label} bound " + "and still match", + ) + for label, val in [("lower", 1), ("upper", 1000)] + ], +] + +SEARCH_AUTOCOMPLETE_TESTS = ( + SEARCH_AUTOCOMPLETE_PREFIX_TESTS + + SEARCH_AUTOCOMPLETE_FUZZY_TESTS + + SEARCH_AUTOCOMPLETE_TOKEN_ORDER_TESTS + + SEARCH_AUTOCOMPLETE_QUERY_ARRAY_TESTS + + SEARCH_AUTOCOMPLETE_FUZZY_PREFIX_EXPANSION_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCH_AUTOCOMPLETE_TESTS)) +def test_search_autocomplete_cases(autocomplete_collection, test_case: StageTestCase): + """Test $search autocomplete edge-gram prefix, fuzzy, and tokenOrder matching.""" + result = execute_command( + autocomplete_collection, + {"aggregate": autocomplete_collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult( + result, + expected=test_case.expected, + msg=test_case.msg, + raw_res=True, + ) + + +# Property [Autocomplete Path Mapping Required]: autocomplete requires the queried +# path to be mapped as an autocomplete field. +SEARCH_AUTOCOMPLETE_PATH_MAPPING_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "autocomplete_path_not_autocomplete_mapped", + pipeline=[ + {"$search": {"autocomplete": {"path": "title", "query": "sep"}}}, + ], + error_code=UNKNOWN_ERROR, + msg="$search autocomplete should reject a path with no autocomplete index field definition", + ), +] + +# Property [Autocomplete fuzzy.maxEdits Range]: autocomplete.fuzzy.maxEdits must +# be 1 or 2, so any value outside that range is rejected. +SEARCH_AUTOCOMPLETE_FUZZY_MAX_EDITS_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"autocomplete_fuzzy_max_edits_{val}", + pipeline=[ + { + "$search": { + "autocomplete": {"path": "ac", "query": "sep", "fuzzy": {"maxEdits": val}} + } + }, + ], + error_code=UNKNOWN_ERROR, + msg=f"$search autocomplete should reject a fuzzy.maxEdits of {val}, which is not 1 or 2", + ) + for val in [0, 3] +] + +# Property [Autocomplete tokenOrder Enum]: autocomplete.tokenOrder must be one of +# "any" or "sequential", so any other value is rejected. +SEARCH_AUTOCOMPLETE_TOKEN_ORDER_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "autocomplete_token_order_bogus", + pipeline=[ + {"$search": {"autocomplete": {"path": "ac", "query": "sep", "tokenOrder": "bogus"}}}, + ], + error_code=UNKNOWN_ERROR, + msg="$search autocomplete should reject a tokenOrder outside the [any, sequential] enum", + ), +] + +# Property [Autocomplete query Validation]: autocomplete.query is required and +# must be a non-empty string or array of non-null strings. +SEARCH_AUTOCOMPLETE_QUERY_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "autocomplete_query_missing", + pipeline=[{"$search": {"autocomplete": {"path": "ac"}}}], + error_code=UNKNOWN_ERROR, + msg="$search autocomplete should reject an operator missing the required query", + ), + StageTestCase( + "autocomplete_query_empty_string", + pipeline=[{"$search": {"autocomplete": {"path": "ac", "query": ""}}}], + error_code=UNKNOWN_ERROR, + msg="$search autocomplete should reject an empty-string query", + ), + StageTestCase( + "autocomplete_query_empty_array", + pipeline=[{"$search": {"autocomplete": {"path": "ac", "query": []}}}], + error_code=UNKNOWN_ERROR, + msg="$search autocomplete should reject an empty-array query", + ), + *[ + StageTestCase( + f"autocomplete_query_non_string_{tid}", + pipeline=[{"$search": {"autocomplete": {"path": "ac", "query": val}}}], + error_code=UNKNOWN_ERROR, + msg=f"$search autocomplete should reject a {tid} query as a non-string", + ) + for tid, val in [ + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("bool", True), + ("object", {"q": "sep"}), + ("objectid", ObjectId("0123456789abcdef01234567")), + ("datetime", datetime.datetime(2020, 1, 1)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ] + ], + StageTestCase( + "autocomplete_query_array_element_null", + pipeline=[{"$search": {"autocomplete": {"path": "ac", "query": ["sep", None]}}}], + error_code=UNKNOWN_ERROR, + msg="$search autocomplete should reject a null query-array element", + ), + StageTestCase( + "autocomplete_query_array_element_non_string", + pipeline=[{"$search": {"autocomplete": {"path": "ac", "query": ["sep", 1]}}}], + error_code=UNKNOWN_ERROR, + msg="$search autocomplete should reject a non-string query-array element", + ), +] + +# Property [Autocomplete path Validation]: autocomplete.path is required and +# string-only, unlike the document and array forms text accepts. +SEARCH_AUTOCOMPLETE_PATH_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "autocomplete_path_missing", + pipeline=[{"$search": {"autocomplete": {"query": "sep"}}}], + error_code=UNKNOWN_ERROR, + msg="$search autocomplete should reject an operator missing the required path", + ), + *[ + StageTestCase( + f"autocomplete_path_{tid}", + pipeline=[{"$search": {"autocomplete": {"path": val, "query": "sep"}}}], + error_code=UNKNOWN_ERROR, + msg=f"$search autocomplete should reject a {tid} path as a non-string type", + ) + for tid, val in [ + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("bool", True), + ("object", {"value": "ac"}), + ("array", ["ac"]), + ("objectid", ObjectId("0123456789abcdef01234567")), + ("datetime", datetime.datetime(2020, 1, 1)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ] + ], +] + +# Property [Autocomplete fuzzy.prefixLength And maxExpansions Bounds]: +# autocomplete.fuzzy.prefixLength must be non-negative and maxExpansions must fall +# within 1..1000, so a negative prefixLength and a maxExpansions outside those +# bounds are rejected. +SEARCH_AUTOCOMPLETE_FUZZY_BOUNDS_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "autocomplete_fuzzy_prefix_length_negative", + pipeline=[ + { + "$search": { + "autocomplete": { + "path": "ac", + "query": "sep", + "fuzzy": {"maxEdits": 1, "prefixLength": -1}, + } + } + }, + ], + error_code=UNKNOWN_ERROR, + msg="$search autocomplete should reject a negative fuzzy.prefixLength", + ), + *[ + StageTestCase( + f"autocomplete_fuzzy_max_expansions_{label}", + pipeline=[ + { + "$search": { + "autocomplete": { + "path": "ac", + "query": "sep", + "fuzzy": {"maxEdits": 1, "maxExpansions": val}, + } + } + }, + ], + error_code=UNKNOWN_ERROR, + msg=f"$search autocomplete should reject a fuzzy.maxExpansions of {val} outside the " + "bounds 1 to 1000", + ) + for label, val in [("zero", 0), ("over_max", 1001)] + ], +] + +SEARCH_AUTOCOMPLETE_ERROR_TESTS = ( + SEARCH_AUTOCOMPLETE_PATH_MAPPING_ERROR_TESTS + + SEARCH_AUTOCOMPLETE_FUZZY_MAX_EDITS_ERROR_TESTS + + SEARCH_AUTOCOMPLETE_TOKEN_ORDER_ERROR_TESTS + + SEARCH_AUTOCOMPLETE_QUERY_ERROR_TESTS + + SEARCH_AUTOCOMPLETE_PATH_ERROR_TESTS + + SEARCH_AUTOCOMPLETE_FUZZY_BOUNDS_ERROR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCH_AUTOCOMPLETE_ERROR_TESTS)) +def test_search_autocomplete_errors(autocomplete_collection, test_case: StageTestCase): + """Test $search autocomplete rejects unmapped paths and bad fuzzy.maxEdits/tokenOrder values.""" + result = execute_command( + autocomplete_collection, + {"aggregate": autocomplete_collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult(result, error_code=test_case.error_code, msg=test_case.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_compound.py b/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_compound.py new file mode 100644 index 000000000..d5c9c4aee --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_compound.py @@ -0,0 +1,463 @@ +"""Tests for the $search compound operator.""" + +from __future__ import annotations + +import datetime + +import pytest +from bson import Binary, Code, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + UNKNOWN_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import ( + Contains, + Len, +) +from documentdb_tests.framework.test_constants import ( + DECIMAL128_ONE_AND_HALF, +) + +pytestmark = pytest.mark.requires(search=True) + + +# Property [Compound Clause Composition]: the must, should, mustNot, and filter +# clauses compose into one clause set, so the matched documents are the must/filter +# intersection minus the mustNot matches, with should only optional. +SEARCH_COMPOUND_COMPOSITION_TESTS: list[StageTestCase] = [ + StageTestCase( + "compound_must_intersects", + pipeline=[ + { + "$search": { + "compound": { + "must": [ + {"text": {"query": "quick", "path": "title"}}, + {"text": {"query": "rabbit", "path": "title"}}, + ] + } + } + }, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 3)]}, + msg="$search compound should intersect multiple must clauses, matching only the " + "document satisfying every clause", + ), + StageTestCase( + "compound_must_not_excludes", + pipeline=[ + { + "$search": { + "compound": { + "must": [{"text": {"query": "quick", "path": "title"}}], + "mustNot": [{"text": {"query": "rabbit", "path": "title"}}], + } + } + }, + ], + expected={"cursor.firstBatch": [Len(2), Contains("_id", 1), Contains("_id", 4)]}, + msg="$search compound should exclude the mustNot matches from the must matches", + ), + StageTestCase( + "compound_filter_selects", + pipeline=[ + {"$search": {"compound": {"filter": [{"text": {"query": "quick", "path": "title"}}]}}}, + ], + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 1), + Contains("_id", 3), + Contains("_id", 4), + ] + }, + msg="$search compound should select the documents matching a filter clause", + ), + StageTestCase( + "compound_all_clause_types", + pipeline=[ + { + "$search": { + "compound": { + "must": [{"text": {"query": "quick", "path": "title"}}], + "should": [{"text": {"query": "brown", "path": "title"}}], + "mustNot": [{"text": {"query": "rabbit", "path": "title"}}], + "filter": [{"text": {"query": "fox", "path": "title"}}], + } + } + }, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 1)]}, + msg="$search compound should compose all four clause types in one clause set, " + "intersecting must and filter and removing the mustNot matches", + ), +] + +# Property [Compound Should-Only Matching]: when a compound has no must or filter +# clause, the should clauses become a required OR (minimumShouldMatch defaults to 1), +# so the matched set is the union of the should clauses. +SEARCH_COMPOUND_SHOULD_ONLY_TESTS: list[StageTestCase] = [ + StageTestCase( + "compound_should_optional_or", + pipeline=[ + { + "$search": { + "compound": { + "should": [ + {"text": {"query": "quick", "path": "title"}}, + {"text": {"query": "turtle", "path": "title"}}, + ] + } + } + }, + ], + expected={ + "cursor.firstBatch": [ + Len(4), + Contains("_id", 1), + Contains("_id", 2), + Contains("_id", 3), + Contains("_id", 4), + ] + }, + msg="$search compound should match the union of its should clauses when no must " + "clause is present", + ), +] + +# Property [Compound minimumShouldMatch]: minimumShouldMatch accepts 0 through the +# number of should clauses and requires at least that many should clauses to match +# alongside the must clause. +SEARCH_COMPOUND_MIN_SHOULD_MATCH_TESTS: list[StageTestCase] = [ + StageTestCase( + "compound_min_should_match_0", + pipeline=[ + { + "$search": { + "compound": { + "must": [{"text": {"query": "quick", "path": "title"}}], + "should": [ + {"text": {"query": "brown", "path": "title"}}, + {"text": {"query": "rabbit", "path": "title"}}, + ], + "minimumShouldMatch": 0, + } + } + }, + ], + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 1), + Contains("_id", 3), + Contains("_id", 4), + ] + }, + msg="$search compound with minimumShouldMatch 0 should require no should clause, " + "matching every must document", + ), + StageTestCase( + "compound_min_should_match_1", + pipeline=[ + { + "$search": { + "compound": { + "must": [{"text": {"query": "quick", "path": "title"}}], + "should": [ + {"text": {"query": "brown", "path": "title"}}, + {"text": {"query": "rabbit", "path": "title"}}, + ], + "minimumShouldMatch": 1, + } + } + }, + ], + expected={"cursor.firstBatch": [Len(2), Contains("_id", 1), Contains("_id", 3)]}, + msg="$search compound with minimumShouldMatch 1 should require at least one should " + "clause to match alongside the must clause", + ), + StageTestCase( + "compound_min_should_match_all", + pipeline=[ + { + "$search": { + "compound": { + "must": [{"text": {"query": "quick", "path": "title"}}], + "should": [ + {"text": {"query": "brown", "path": "title"}}, + {"text": {"query": "rabbit", "path": "title"}}, + ], + "minimumShouldMatch": 2, + } + } + }, + ], + expected={"cursor.firstBatch": Len(0)}, + msg="$search compound with minimumShouldMatch equal to the should-clause count should " + "require every should clause to match", + ), +] + +# Property [Compound Score And Nesting]: a clause-level score boost, a +# compound-level score, and a compound nested at least three levels deep all +# execute and return the matched documents. +SEARCH_COMPOUND_SCORE_NESTING_TESTS: list[StageTestCase] = [ + StageTestCase( + "compound_clause_level_score_boost", + pipeline=[ + { + "$search": { + "compound": { + "must": [ + { + "text": { + "query": "quick", + "path": "title", + "score": {"boost": {"value": 2.0}}, + } + } + ] + } + } + }, + ], + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 1), + Contains("_id", 3), + Contains("_id", 4), + ] + }, + msg="$search compound should accept a clause-level score boost and still return the " + "matches", + ), + StageTestCase( + "compound_level_score", + pipeline=[ + { + "$search": { + "compound": { + "must": [{"text": {"query": "quick", "path": "title"}}], + "score": {"boost": {"value": 2.0}}, + } + } + }, + ], + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 1), + Contains("_id", 3), + Contains("_id", 4), + ] + }, + msg="$search compound should accept a compound-level score and still return the matches", + ), + StageTestCase( + "compound_nested_three_levels", + pipeline=[ + { + "$search": { + "compound": { + "must": [ + { + "compound": { + "must": [ + { + "compound": { + "must": [ + { + "text": { + "query": "quick", + "path": "title", + } + } + ] + } + } + ] + } + } + ] + } + } + }, + ], + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 1), + Contains("_id", 3), + Contains("_id", 4), + ] + }, + msg="$search compound should execute a compound nested at least three levels deep", + ), +] + +# Property [Compound Single-Document Clause]: a compound clause accepts a single +# operator document in place of a one-element array, matching identically to the +# array-wrapped form. +SEARCH_COMPOUND_SINGLE_DOC_CLAUSE_TESTS: list[StageTestCase] = [ + StageTestCase( + "compound_single_doc_must", + pipeline=[ + {"$search": {"compound": {"must": {"text": {"query": "quick", "path": "title"}}}}}, + ], + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 1), + Contains("_id", 3), + Contains("_id", 4), + ] + }, + msg="$search compound should accept a single operator document as a must clause, " + "matching identically to the array-wrapped form", + ), +] + +SEARCH_COMPOUND_TESTS = ( + SEARCH_COMPOUND_COMPOSITION_TESTS + + SEARCH_COMPOUND_SHOULD_ONLY_TESTS + + SEARCH_COMPOUND_MIN_SHOULD_MATCH_TESTS + + SEARCH_COMPOUND_SCORE_NESTING_TESTS + + SEARCH_COMPOUND_SINGLE_DOC_CLAUSE_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCH_COMPOUND_TESTS)) +def test_search_compound_cases(indexed_collection, test_case: StageTestCase): + """Test $search compound clause composition, should-only, minimumShouldMatch, scoring.""" + result = execute_command( + indexed_collection, + {"aggregate": indexed_collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult( + result, + expected=test_case.expected, + msg=test_case.msg, + raw_res=True, + ) + + +# Property [Compound minimumShouldMatch Bounds]: compound.minimumShouldMatch must +# be between 0 and the number of should clauses, so a negative value or one +# greater than the should-clause count is rejected. +SEARCH_COMPOUND_MIN_SHOULD_MATCH_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "min_should_match_negative", + pipeline=[ + { + "$search": { + "compound": { + "should": [{"text": {"query": "quick", "path": "title"}}], + "minimumShouldMatch": -1, + } + } + }, + ], + error_code=UNKNOWN_ERROR, + msg="$search should reject a negative compound.minimumShouldMatch", + ), + StageTestCase( + "min_should_match_over_clause_count", + pipeline=[ + { + "$search": { + "compound": { + "should": [{"text": {"query": "quick", "path": "title"}}], + "minimumShouldMatch": 2, + } + } + }, + ], + error_code=UNKNOWN_ERROR, + msg="$search should reject a compound.minimumShouldMatch greater than the should-clause " + "count", + ), +] + +# Property [Compound minimumShouldMatch Type]: compound.minimumShouldMatch must +# be an integer, so a non-integer type is rejected and null is treated as the default. +SEARCH_COMPOUND_MIN_SHOULD_MATCH_TYPE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"min_should_match_type_{tid}", + pipeline=[ + { + "$search": { + "compound": { + "should": [{"text": {"query": "quick", "path": "title"}}], + "minimumShouldMatch": val, + } + } + }, + ], + error_code=UNKNOWN_ERROR, + msg=f"$search should reject a {tid} compound.minimumShouldMatch as a non-integer", + ) + for tid, val in [ + ("string", "1"), + ("double", 1.5), + ("bool", True), + ("object", {"a": 1}), + ("array", [1]), + ("objectid", ObjectId("0123456789abcdef01234567")), + ("datetime", datetime.datetime(2020, 1, 1)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ] +] + +# Property [Compound Requires A Clause]: a compound with none of the four clause +# types present is rejected as it composes no clauses. +SEARCH_COMPOUND_EMPTY_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "compound_empty", + pipeline=[{"$search": {"compound": {}}}], + error_code=UNKNOWN_ERROR, + msg="$search should reject an empty compound with no clause type present", + ), +] + +# Property [Compound Clause Array Non-Empty]: a present compound clause array must +# contain at least one clause, so an empty array in any clause slot is rejected. +SEARCH_COMPOUND_EMPTY_CLAUSE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "compound_empty_must", + pipeline=[{"$search": {"compound": {"must": []}}}], + error_code=UNKNOWN_ERROR, + msg="$search should reject an empty compound must clause array", + ), +] + +SEARCH_COMPOUND_ERROR_TESTS = ( + SEARCH_COMPOUND_MIN_SHOULD_MATCH_ERROR_TESTS + + SEARCH_COMPOUND_MIN_SHOULD_MATCH_TYPE_ERROR_TESTS + + SEARCH_COMPOUND_EMPTY_ERROR_TESTS + + SEARCH_COMPOUND_EMPTY_CLAUSE_ERROR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCH_COMPOUND_ERROR_TESTS)) +def test_search_compound_errors(indexed_collection, test_case: StageTestCase): + """Test $search compound minimumShouldMatch and empty-clause validation errors.""" + result = execute_command( + indexed_collection, + {"aggregate": indexed_collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult(result, error_code=test_case.error_code, msg=test_case.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_count.py b/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_count.py new file mode 100644 index 000000000..f5fef1cad --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_count.py @@ -0,0 +1,296 @@ +"""Tests for the $search count option and count metadata.""" + +from __future__ import annotations + +import datetime + +import pytest +from bson import Binary, Code, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + UNKNOWN_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import ( + Contains, + Eq, + Len, +) +from documentdb_tests.framework.test_constants import ( + DECIMAL128_ONE_AND_HALF, + FLOAT_INFINITY, + FLOAT_NEGATIVE_INFINITY, +) + +pytestmark = pytest.mark.requires(search=True) + + +# Property [Count Option Default]: an empty count document is accepted (the +# default lowerBound behavior) and the search still returns its matches. +SEARCH_COUNT_OPTION_TESTS: list[StageTestCase] = [ + StageTestCase( + "count_empty_document", + pipeline=[ + {"$search": {"text": {"query": "quick", "path": "title"}, "count": {}}}, + ], + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 1), + Contains("_id", 3), + Contains("_id", 4), + ] + }, + msg="$search should accept an empty count document and still return its matches", + ), +] + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCH_COUNT_OPTION_TESTS)) +def test_search_count_option_cases(indexed_collection, test_case: StageTestCase): + """Test $search accepts the count option document.""" + result = execute_command( + indexed_collection, + {"aggregate": indexed_collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult( + result, + expected=test_case.expected, + msg=test_case.msg, + raw_res=True, + ) + + +# Property [Count Metadata]: a count option exposes the result count through +# $$SEARCH_META.count under the requested type key, and accepts count.threshold +# for both count types. +SEARCH_COUNT_TESTS: list[StageTestCase] = [ + StageTestCase( + "count_total", + pipeline=[ + {"$search": {"text": {"query": "quick", "path": "title"}, "count": {"type": "total"}}}, + {"$limit": 1}, + {"$project": {"_id": 0, "count": "$$SEARCH_META.count"}}, + ], + expected={"count": {"total": Eq(Int64(3))}}, + msg="$search should expose an exact total result count through $$SEARCH_META", + ), + StageTestCase( + "count_lower_bound", + pipeline=[ + { + "$search": { + "text": {"query": "quick", "path": "title"}, + "count": {"type": "lowerBound"}, + } + }, + {"$limit": 1}, + {"$project": {"_id": 0, "count": "$$SEARCH_META.count"}}, + ], + expected={"count": {"lowerBound": Eq(Int64(3))}}, + msg="$search should expose a lowerBound result count through $$SEARCH_META", + ), + StageTestCase( + "count_total_threshold", + pipeline=[ + { + "$search": { + "text": {"query": "quick", "path": "title"}, + "count": {"type": "total", "threshold": 10_000}, + } + }, + {"$limit": 1}, + {"$project": {"_id": 0, "count": "$$SEARCH_META.count"}}, + ], + expected={"count": {"total": Eq(Int64(3))}}, + msg="$search should accept count.threshold for a total count and still expose the " + "exact count", + ), + StageTestCase( + "count_lower_bound_threshold", + pipeline=[ + { + "$search": { + "text": {"query": "quick", "path": "title"}, + "count": {"type": "lowerBound", "threshold": 10_000}, + } + }, + {"$limit": 1}, + {"$project": {"_id": 0, "count": "$$SEARCH_META.count"}}, + ], + expected={"count": {"lowerBound": Eq(Int64(3))}}, + msg="$search should accept count.threshold for a lowerBound count and still expose " + "the exact count", + ), + StageTestCase( + "count_type_null_default", + pipeline=[ + {"$search": {"text": {"query": "quick", "path": "title"}, "count": {"type": None}}}, + {"$limit": 1}, + {"$project": {"_id": 0, "count": "$$SEARCH_META.count"}}, + ], + expected={"count": {"lowerBound": Eq(Int64(3))}}, + msg="$search should treat a null count.type as the unset default, exposing a " + "lowerBound count", + ), + StageTestCase( + "count_threshold_null_default", + pipeline=[ + { + "$search": { + "text": {"query": "quick", "path": "title"}, + "count": {"type": "total", "threshold": None}, + } + }, + {"$limit": 1}, + {"$project": {"_id": 0, "count": "$$SEARCH_META.count"}}, + ], + expected={"count": {"total": Eq(Int64(3))}}, + msg="$search should treat a null count.threshold as the unset default and still " + "expose the exact count", + ), +] + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCH_COUNT_TESTS)) +def test_search_count_metadata(indexed_collection, test_case: StageTestCase): + """Test $search exposes a result count through $$SEARCH_META.count.""" + result = execute_command( + indexed_collection, + {"aggregate": indexed_collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult(result, expected=test_case.expected, msg=test_case.msg) + + +# Property [Count Type Enum]: count.type must be one of the recognized count-type +# strings, so an unrecognized string or a non-string type is rejected (null is +# the default). +SEARCH_COUNT_TYPE_ENUM_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "count_type_bogus", + pipeline=[ + {"$search": {"text": {"query": "quick", "path": "title"}, "count": {"type": "bogus"}}}, + ], + error_code=UNKNOWN_ERROR, + msg="$search should reject a count.type outside the recognized set of count types", + ), + *[ + StageTestCase( + f"count_type_type_{tid}", + pipeline=[ + {"$search": {"text": {"query": "quick", "path": "title"}, "count": {"type": val}}}, + ], + error_code=UNKNOWN_ERROR, + msg=f"$search should reject a {tid} count.type as a non-string", + ) + for tid, val in [ + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("bool", True), + ("object", {"a": 1}), + ("array", ["total"]), + ("objectid", ObjectId("0123456789abcdef01234567")), + ("datetime", datetime.datetime(2020, 1, 1)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ] + ], +] + +# Property [Count threshold Value And Type]: count.threshold must be a +# non-negative integer (null is the default). +SEARCH_COUNT_THRESHOLD_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "count_threshold_negative", + pipeline=[ + {"$search": {"text": {"query": "quick", "path": "title"}, "count": {"threshold": -1}}}, + ], + error_code=UNKNOWN_ERROR, + msg="$search should reject a negative count.threshold", + ), + StageTestCase( + "count_threshold_positive_infinity", + pipeline=[ + { + "$search": { + "text": {"query": "quick", "path": "title"}, + "count": {"threshold": FLOAT_INFINITY}, + } + }, + ], + error_code=UNKNOWN_ERROR, + msg="$search should reject an infinite count.threshold as not fitting in a 32-bit " + "integer", + ), + StageTestCase( + "count_threshold_negative_infinity", + pipeline=[ + { + "$search": { + "text": {"query": "quick", "path": "title"}, + "count": {"threshold": FLOAT_NEGATIVE_INFINITY}, + } + }, + ], + error_code=UNKNOWN_ERROR, + msg="$search should reject a negative-infinite count.threshold as not fitting in a " + "32-bit integer", + ), + *[ + StageTestCase( + f"count_threshold_type_{tid}", + pipeline=[ + { + "$search": { + "text": {"query": "quick", "path": "title"}, + "count": {"threshold": val}, + } + }, + ], + error_code=UNKNOWN_ERROR, + msg=f"$search should reject a {tid} count.threshold as a non-integer", + ) + for tid, val in [ + ("string", "10"), + ("double", 1.5), + ("bool", True), + ("object", {"a": 1}), + ("array", [10]), + ("objectid", ObjectId("0123456789abcdef01234567")), + ("datetime", datetime.datetime(2020, 1, 1)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ] + ], +] + +SEARCH_COUNT_ERROR_TESTS = SEARCH_COUNT_TYPE_ENUM_ERROR_TESTS + SEARCH_COUNT_THRESHOLD_ERROR_TESTS + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCH_COUNT_ERROR_TESTS)) +def test_search_count_errors(indexed_collection, test_case: StageTestCase): + """Test $search count type/enum and threshold validation errors.""" + result = execute_command( + indexed_collection, + {"aggregate": indexed_collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult(result, error_code=test_case.error_code, msg=test_case.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_embeddedDocument.py b/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_embeddedDocument.py new file mode 100644 index 000000000..34ac9f0e6 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_embeddedDocument.py @@ -0,0 +1,130 @@ +"""Tests for the $search embeddedDocument operator.""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.stages.search.utils.search_common import ( + create_search_index, +) +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, +) +from documentdb_tests.framework import fixtures +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import UNKNOWN_ERROR +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Contains, Len + +pytestmark = pytest.mark.requires(search=True) + +_EMBEDDED_DOCS = [ + {"_id": 1, "items": [{"name": "quick fox"}, {"name": "slow dog"}]}, + {"_id": 2, "items": [{"name": "quick cat"}]}, + {"_id": 3, "items": [{"name": "lazy bird"}]}, +] + +_EMBEDDED_INDEX_DEFINITION = { + "mappings": { + "dynamic": False, + "fields": {"items": {"type": "embeddedDocuments", "dynamic": True}}, + } +} + + +@pytest.fixture(scope="module") +def embedded_collection(engine_client, worker_id): + """A module-scoped collection whose items array is indexed as embeddedDocuments, + so the embeddedDocument operator can match embedded fields independently.""" + db_name = fixtures.generate_database_name("stages_search_embedded", worker_id) + fixtures.cleanup_database(engine_client, db_name) + db = engine_client[db_name] + coll = db["embedded"] + coll.insert_many(_EMBEDDED_DOCS) + create_search_index(coll, _EMBEDDED_INDEX_DEFINITION) + yield coll + fixtures.cleanup_database(engine_client, db_name) + + +# Property [embeddedDocument Matching]: the operator runs its inner operator +# against the embedded documents on the indexed path and returns the parent +# documents that contain a matching embedded document. +SEARCH_EMBEDDED_MATCHING_TESTS: list[StageTestCase] = [ + StageTestCase( + "embedded_matches_parents", + pipeline=[ + { + "$search": { + "embeddedDocument": { + "path": "items", + "operator": {"text": {"query": "quick", "path": "items.name"}}, + } + } + } + ], + expected={"cursor.firstBatch": [Len(2), Contains("_id", 1), Contains("_id", 2)]}, + msg="$search embeddedDocument should return parents whose embedded document matches the " + "inner operator", + ), + StageTestCase( + "embedded_matches_single_parent", + pipeline=[ + { + "$search": { + "embeddedDocument": { + "path": "items", + "operator": {"text": {"query": "lazy", "path": "items.name"}}, + } + } + } + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 3)]}, + msg="$search embeddedDocument should return only the parent whose embedded document " + "matches", + ), +] + +# Property [embeddedDocument Validation]: the operator requires both a path and +# an inner operator. +SEARCH_EMBEDDED_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "embedded_missing_operator", + pipeline=[{"$search": {"embeddedDocument": {"path": "items"}}}], + error_code=UNKNOWN_ERROR, + msg="$search embeddedDocument should reject a spec missing the required operator", + ), + StageTestCase( + "embedded_missing_path", + pipeline=[ + { + "$search": { + "embeddedDocument": { + "operator": {"text": {"query": "quick", "path": "items.name"}} + } + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$search embeddedDocument should reject a spec missing the required path", + ), +] + +SEARCH_EMBEDDED_TESTS = SEARCH_EMBEDDED_MATCHING_TESTS + SEARCH_EMBEDDED_ERROR_TESTS + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCH_EMBEDDED_TESTS)) +def test_search_embeddedDocument_cases(embedded_collection, test_case: StageTestCase): + """Test $search embeddedDocument matching and validation.""" + result = execute_command( + embedded_collection, + {"aggregate": embedded_collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_equals.py b/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_equals.py new file mode 100644 index 000000000..472643068 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_equals.py @@ -0,0 +1,409 @@ +"""Tests for the $search equals operator.""" + +from __future__ import annotations + +import datetime +import uuid + +import pytest +from bson import Binary, Code, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.stages.search.utils.search_common import ( + create_search_index, +) +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, +) +from documentdb_tests.framework import fixtures +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + UNKNOWN_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import ( + Contains, + Len, +) +from documentdb_tests.framework.test_constants import ( + DECIMAL128_ONE_AND_HALF, + DOUBLE_FROM_INT64_MAX, + DOUBLE_MAX_SAFE_INTEGER, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_PRECISION_LOSS, + DOUBLE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + INT64_MAX, +) + +pytestmark = pytest.mark.requires(search=True) + + +_EQUALS_OBJECT_ID = ObjectId("0123456789abcdef01234567") + +_EQUALS_UUID = uuid.UUID("12345678-1234-4567-8901-123456789abc") + +_EQUALS_DATE = datetime.datetime(2020, 1, 1) + +_EQUALS_DOCS = [ + {"_id": 1, "b": True}, + {"_id": 2, "b": False}, + {"_id": 3, "oid": _EQUALS_OBJECT_ID}, + {"_id": 4, "dt": _EQUALS_DATE}, + {"_id": 5, "s": "apple"}, # stored on a token-mapped path + {"_id": 6, "nullf": None}, + {"_id": 7, "u": Binary.from_uuid(_EQUALS_UUID)}, + {"_id": 8, "num": 20}, # int32 + {"_id": 9, "num": Int64(20)}, # int64 + {"_id": 10, "num": 20.0}, # double + {"_id": 11, "big": Int64(DOUBLE_PRECISION_LOSS)}, # 2^53+1 (no exact double) + {"_id": 12, "big": float(DOUBLE_MAX_SAFE_INTEGER)}, # 2^53 + {"_id": 13, "imax": INT64_MAX}, # int64-max + {"_id": 14, "imax": DOUBLE_FROM_INT64_MAX}, # int64-max's double approximation + {"_id": 15, "zero": DOUBLE_ZERO}, + {"_id": 16, "zero": DOUBLE_NEGATIVE_ZERO}, + {"_id": 17, "nf": FLOAT_NAN}, + {"_id": 18, "nf": FLOAT_INFINITY}, + {"_id": 19, "nf": FLOAT_NEGATIVE_INFINITY}, +] + +_EQUALS_INDEX_DEFINITION = { + "mappings": { + "dynamic": False, + "fields": { + "b": {"type": "boolean"}, + "oid": {"type": "objectId"}, + "dt": {"type": "date"}, + "s": {"type": "token"}, + "txt": {"type": "string"}, + "nf": {"type": "number"}, + "nullf": {"type": "token"}, + "u": {"type": "uuid"}, + "num": {"type": "number"}, + "big": {"type": "number"}, + "imax": {"type": "number"}, + "zero": {"type": "number"}, + }, + } +} + + +@pytest.fixture(scope="module") +def equals_collection(engine_client, worker_id): + """A module-scoped collection with a static search index mapping a field of + each equals-supported value type (boolean, objectId, date, token string, null, + uuid, and number), shared read-only across the equals cases so the index is + built and polled once.""" + db_name = fixtures.generate_database_name("stages_search_equals", worker_id) + fixtures.cleanup_database(engine_client, db_name) + db = engine_client[db_name] + coll = db["equals"] + coll.insert_many(_EQUALS_DOCS) + create_search_index(coll, _EQUALS_INDEX_DEFINITION) + yield coll + fixtures.cleanup_database(engine_client, db_name) + + +# Property [Equals Value-Type Match]: equals returns the document storing a value +# exactly equal to the queried value, for each supported value type. +SEARCH_EQUALS_VALUE_TYPE_TESTS: list[StageTestCase] = [ + StageTestCase( + "equals_bool_true", + pipeline=[{"$search": {"equals": {"path": "b", "value": True}}}], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 1)]}, + msg="$search equals should match the document storing the queried boolean true", + ), + StageTestCase( + "equals_score_boost", + pipeline=[ + { + "$search": { + "equals": {"path": "b", "value": True, "score": {"boost": {"value": 2.0}}} + } + }, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 1)]}, + msg="$search equals should accept a score modifier and still return its match", + ), + StageTestCase( + "equals_bool_false", + pipeline=[{"$search": {"equals": {"path": "b", "value": False}}}], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 2)]}, + msg="$search equals should match the document storing the queried boolean false", + ), + StageTestCase( + "equals_object_id", + pipeline=[ + {"$search": {"equals": {"path": "oid", "value": _EQUALS_OBJECT_ID}}}, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 3)]}, + msg="$search equals should match the document storing the queried ObjectId", + ), + StageTestCase( + "equals_date", + pipeline=[ + {"$search": {"equals": {"path": "dt", "value": _EQUALS_DATE}}}, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 4)]}, + msg="$search equals should match the document storing the queried date", + ), + StageTestCase( + "equals_string_token", + pipeline=[ + {"$search": {"equals": {"path": "s", "value": "apple"}}}, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 5)]}, + msg="$search equals should match the document storing the queried string on a " + "token-mapped path", + ), + StageTestCase( + "equals_null", + pipeline=[ + {"$search": {"equals": {"path": "nullf", "value": None}}}, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 6)]}, + msg="$search equals should match the document storing the queried null value", + ), + StageTestCase( + "equals_uuid", + pipeline=[ + {"$search": {"equals": {"path": "u", "value": Binary.from_uuid(_EQUALS_UUID)}}}, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 7)]}, + msg="$search equals should match the document storing the queried UUID (Binary subtype 4)", + ), +] + +# Property [Equals Lossy Double Numeric Equality]: equals compares numbers in +# double space, so all numeric representations of one value match each other and +# a value with no exact double matches every representation that narrows to the +# same double. +SEARCH_EQUALS_NUMERIC_TESTS: list[StageTestCase] = [ + StageTestCase( + "equals_int_matches_all_representations", + pipeline=[{"$search": {"equals": {"path": "num", "value": 20}}}], + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 8), + Contains("_id", 9), + Contains("_id", 10), + ] + }, + msg="$search equals with an int value should match the int32, int64, and double " + "representations of the same integer", + ), + StageTestCase( + "equals_double_matches_all_representations", + pipeline=[ + {"$search": {"equals": {"path": "num", "value": 20.0}}}, + ], + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 8), + Contains("_id", 9), + Contains("_id", 10), + ] + }, + msg="$search equals with a double value should match the int32, int64, and double " + "representations of the same integer", + ), + StageTestCase( + "equals_int64_2pow53_plus_1", + pipeline=[ + {"$search": {"equals": {"path": "big", "value": Int64(DOUBLE_PRECISION_LOSS)}}}, + ], + expected={"cursor.firstBatch": [Len(2), Contains("_id", 11), Contains("_id", 12)]}, + msg="$search equals with an int64 having no exact double should match both stored " + "representations that narrow to the same double", + ), + StageTestCase( + "equals_double_2pow53", + pipeline=[ + {"$search": {"equals": {"path": "big", "value": float(DOUBLE_MAX_SAFE_INTEGER)}}}, + ], + expected={"cursor.firstBatch": [Len(2), Contains("_id", 11), Contains("_id", 12)]}, + msg="$search equals with a double should match both stored representations that " + "narrow to the same double", + ), + StageTestCase( + "equals_int64_max", + pipeline=[ + {"$search": {"equals": {"path": "imax", "value": INT64_MAX}}}, + ], + expected={"cursor.firstBatch": [Len(2), Contains("_id", 13), Contains("_id", 14)]}, + msg="$search equals with int64-max should match int64-max and its double approximation", + ), +] + +# Property [Equals Negative Zero]: equals treats negative zero as equal to positive +# zero, so a 0.0 or -0.0 query each matches both a stored 0.0 and a stored -0.0. +SEARCH_EQUALS_NEGATIVE_ZERO_TESTS: list[StageTestCase] = [ + StageTestCase( + "equals_zero_double_positive", + pipeline=[ + {"$search": {"equals": {"path": "zero", "value": DOUBLE_ZERO}}}, + ], + expected={"cursor.firstBatch": [Len(2), Contains("_id", 15), Contains("_id", 16)]}, + msg="$search equals with positive-zero double should match both stored 0.0 and -0.0", + ), + StageTestCase( + "equals_zero_double_negative", + pipeline=[ + {"$search": {"equals": {"path": "zero", "value": DOUBLE_NEGATIVE_ZERO}}}, + ], + expected={"cursor.firstBatch": [Len(2), Contains("_id", 15), Contains("_id", 16)]}, + msg="$search equals with negative-zero double should match both stored 0.0 and -0.0", + ), +] + +# Property [Equals Non-Finite No Match]: equals never matches a stored NaN, +inf, +# or -inf, unlike in which matches them. +SEARCH_EQUALS_NON_FINITE_TESTS: list[StageTestCase] = [ + StageTestCase( + "equals_nan", + pipeline=[ + {"$search": {"equals": {"path": "nf", "value": FLOAT_NAN}}}, + ], + expected={"cursor.firstBatch": Len(0)}, + msg="$search equals should never match a stored NaN", + ), + StageTestCase( + "equals_positive_infinity", + pipeline=[ + {"$search": {"equals": {"path": "nf", "value": FLOAT_INFINITY}}}, + ], + expected={"cursor.firstBatch": Len(0)}, + msg="$search equals should never match a stored +inf", + ), + StageTestCase( + "equals_negative_infinity", + pipeline=[ + {"$search": {"equals": {"path": "nf", "value": FLOAT_NEGATIVE_INFINITY}}}, + ], + expected={"cursor.firstBatch": Len(0)}, + msg="$search equals should never match a stored -inf", + ), +] + +# Property [Equals doesNotAffect Option]: equals recognizes a string doesNotAffect +# option (unlike text or near, which reject the field), accepting it and still +# returning its match. +SEARCH_EQUALS_DOES_NOT_AFFECT_TESTS: list[StageTestCase] = [ + StageTestCase( + "equals_does_not_affect_string", + pipeline=[{"$search": {"equals": {"path": "b", "value": True, "doesNotAffect": "score"}}}], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 1)]}, + msg="$search equals should accept a string doesNotAffect option and still return its match", + ), +] + +SEARCH_EQUALS_TESTS = ( + SEARCH_EQUALS_VALUE_TYPE_TESTS + + SEARCH_EQUALS_NUMERIC_TESTS + + SEARCH_EQUALS_NEGATIVE_ZERO_TESTS + + SEARCH_EQUALS_NON_FINITE_TESTS + + SEARCH_EQUALS_DOES_NOT_AFFECT_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCH_EQUALS_TESTS)) +def test_search_equals_cases(equals_collection, test_case: StageTestCase): + """Test $search equals value semantics across the supported value types.""" + result = execute_command( + equals_collection, + {"aggregate": equals_collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult( + result, + expected=test_case.expected, + msg=test_case.msg, + raw_res=True, + ) + + +# Property [Equals Required Fields]: equals.path and equals.value are both +# required, so a spec omitting either is rejected. +SEARCH_EQUALS_REQUIRED_FIELD_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "equals_path_missing", + pipeline=[{"$search": {"equals": {"value": True}}}], + error_code=UNKNOWN_ERROR, + msg="$search equals should reject a spec that omits the required path", + ), + StageTestCase( + "equals_value_missing", + pipeline=[{"$search": {"equals": {"path": "b"}}}], + error_code=UNKNOWN_ERROR, + msg="$search equals should reject a spec that omits the required value", + ), +] + +# Property [Equals Value Type Rejection]: equals.value rejects any type outside +# the supported set (bool, objectId, number, string, date, uuid, null). +SEARCH_EQUALS_VALUE_TYPE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"equals_value_type_{tid}", + pipeline=[{"$search": {"equals": {"path": "num", "value": val}}}], + error_code=UNKNOWN_ERROR, + msg=f"$search equals should reject a {tid} value as an unsupported type", + ) + for tid, val in [ + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("timestamp", Timestamp(1, 1)), + ("array", [1, 2]), + ("object", {"a": 1}), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Equals Binary Subtype]: a Binary equals.value is accepted only as a +# UUID (subtype 4), so a Binary of any other subtype is rejected. +SEARCH_EQUALS_BINARY_SUBTYPE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "equals_binary_non_uuid", + pipeline=[ + {"$search": {"equals": {"path": "u", "value": Binary(b"\x01\x02\x03")}}}, + ], + error_code=UNKNOWN_ERROR, + msg="$search equals should reject a Binary value that is not UUID subtype 4", + ), +] + +# Property [Equals Analyzed Path]: a string equals.value requires a token-mapped +# path. +SEARCH_EQUALS_ANALYZED_PATH_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "equals_string_analyzed_path", + pipeline=[ + {"$search": {"equals": {"path": "txt", "value": "quick brown"}}}, + ], + error_code=UNKNOWN_ERROR, + msg="$search equals should reject a string value against an analyzed non-token path", + ), +] + +SEARCH_EQUALS_ERROR_TESTS = ( + SEARCH_EQUALS_REQUIRED_FIELD_ERROR_TESTS + + SEARCH_EQUALS_VALUE_TYPE_ERROR_TESTS + + SEARCH_EQUALS_BINARY_SUBTYPE_ERROR_TESTS + + SEARCH_EQUALS_ANALYZED_PATH_ERROR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCH_EQUALS_ERROR_TESTS)) +def test_search_equals_errors(equals_collection, test_case: StageTestCase): + """Test $search equals rejects unsupported value types, non-UUID Binary, and analyzed paths.""" + result = execute_command( + equals_collection, + {"aggregate": equals_collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult(result, error_code=test_case.error_code, msg=test_case.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_exists.py b/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_exists.py new file mode 100644 index 000000000..7a8a15420 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_exists.py @@ -0,0 +1,177 @@ +"""Tests for the $search exists operator.""" + +from __future__ import annotations + +import datetime + +import pytest +from bson import Binary, Code, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + UNKNOWN_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import ( + Contains, + Len, +) +from documentdb_tests.framework.test_constants import ( + DECIMAL128_ONE_AND_HALF, +) + +pytestmark = pytest.mark.requires(search=True) + + +# Property [Exists Field Presence]: exists selects only the documents where the +# named field is present, and inside a compound mustNot clause selects the +# complement (the absent-field documents). +SEARCH_EXISTS_PRESENCE_TESTS: list[StageTestCase] = [ + StageTestCase( + "exists_field_present", + pipeline=[{"$search": {"exists": {"path": "body"}}}], + expected={"cursor.firstBatch": [Len(2), Contains("_id", 1), Contains("_id", 2)]}, + msg="$search exists should select only the documents where the named field is present", + ), + StageTestCase( + "exists_score_boost", + pipeline=[ + {"$search": {"exists": {"path": "body", "score": {"boost": {"value": 2.0}}}}}, + ], + expected={"cursor.firstBatch": [Len(2), Contains("_id", 1), Contains("_id", 2)]}, + msg="$search exists should accept a score modifier and still return its matches", + ), + StageTestCase( + "exists_nonexistent_field", + pipeline=[{"$search": {"exists": {"path": "nope"}}}], + expected={"cursor.firstBatch": Len(0)}, + msg="$search exists should match nothing for a field no document carries", + ), + StageTestCase( + "exists_compound_must_not_complement", + pipeline=[ + {"$search": {"compound": {"mustNot": [{"exists": {"path": "body"}}]}}}, + ], + expected={ + "cursor.firstBatch": [Len(16), *[Contains("_id", _id) for _id in list(range(3, 19))]] + }, + msg="$search exists inside a compound mustNot should select the complement of the " + "present-field documents", + ), +] + +# Property [Exists Path No Validation]: an empty or dotted absent path resolves to +# no covered field and returns no documents without field-path validation or error. +SEARCH_EXISTS_PATH_NO_VALIDATION_TESTS: list[StageTestCase] = [ + StageTestCase( + "exists_path_empty", + pipeline=[{"$search": {"exists": {"path": ""}}}], + expected={"cursor.firstBatch": Len(0)}, + msg="$search exists should treat an empty path as an absent field and match nothing " + "without error", + ), + StageTestCase( + "exists_path_dotted", + pipeline=[{"$search": {"exists": {"path": "a.b"}}}], + expected={"cursor.firstBatch": Len(0)}, + msg="$search exists should treat a dotted absent path as an absent field and match " + "nothing without field-path validation", + ), +] + +SEARCH_EXISTS_TESTS = SEARCH_EXISTS_PRESENCE_TESTS + SEARCH_EXISTS_PATH_NO_VALIDATION_TESTS + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCH_EXISTS_TESTS)) +def test_search_exists_cases(indexed_collection, test_case: StageTestCase): + """Test $search exists field presence and path no-validation.""" + result = execute_command( + indexed_collection, + {"aggregate": indexed_collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult( + result, + expected=test_case.expected, + msg=test_case.msg, + raw_res=True, + ) + + +# Property [Exists Path Type Rejection]: exists.path is string-only, so a path of +# any non-string type - including the document forms and the array of paths that +# text and wildcard accept - is rejected. +SEARCH_EXISTS_PATH_TYPE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"exists_path_type_{tid}", + pipeline=[{"$search": {"exists": {"path": val}}}], + error_code=UNKNOWN_ERROR, + msg=f"$search exists should reject a {tid} path as a non-string type", + ) + for tid, val in [ + ("object", {"value": "body"}), + ("array", ["body"]), + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("bool", True), + ("objectid", ObjectId("0123456789abcdef01234567")), + ("datetime", datetime.datetime(2020, 1, 1)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ] +] + +# Property [Exists Path Required]: a missing or null exists.path is treated as +# absent and produces a spec validation error. +SEARCH_EXISTS_PATH_REQUIRED_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "exists_path_missing", + pipeline=[{"$search": {"exists": {}}}], + error_code=UNKNOWN_ERROR, + msg="$search exists should reject a missing path as required", + ), + StageTestCase( + "exists_path_null", + pipeline=[{"$search": {"exists": {"path": None}}}], + error_code=UNKNOWN_ERROR, + msg="$search exists should reject a null path treated as missing", + ), +] + +# Property [Exists Unknown Sub-field]: an unrecognized exists sub-field produces a +# spec validation error. +SEARCH_EXISTS_UNKNOWN_FIELD_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "exists_unknown_field", + pipeline=[{"$search": {"exists": {"path": "body", "bogus": 1}}}], + error_code=UNKNOWN_ERROR, + msg="$search exists should reject an unrecognized sub-field", + ), +] + +SEARCH_EXISTS_ERROR_TESTS = ( + SEARCH_EXISTS_PATH_TYPE_ERROR_TESTS + + SEARCH_EXISTS_PATH_REQUIRED_ERROR_TESTS + + SEARCH_EXISTS_UNKNOWN_FIELD_ERROR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCH_EXISTS_ERROR_TESTS)) +def test_search_exists_errors(indexed_collection, test_case: StageTestCase): + """Test $search exists rejects non-string, missing/null, and unknown-field paths.""" + result = execute_command( + indexed_collection, + {"aggregate": indexed_collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult(result, error_code=test_case.error_code, msg=test_case.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_facet.py b/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_facet.py new file mode 100644 index 000000000..9a8ecaffb --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_facet.py @@ -0,0 +1,94 @@ +"""Tests for the $search facet collector.""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.stages.search.utils.search_common import ( + create_search_index, +) +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, +) +from documentdb_tests.framework import fixtures +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.property_checks import ( + Contains, + Len, +) + +pytestmark = pytest.mark.requires(search=True) + + +_FACET_DOCS = [ + {"_id": 1, "title": "the quick brown fox", "cat": "a"}, + {"_id": 2, "title": "slow green turtle", "cat": "b"}, + {"_id": 3, "title": "a quick quick rabbit", "cat": "a"}, +] + +_FACET_INDEX_DEFINITION = { + "mappings": { + "dynamic": False, + "fields": { + "title": {"type": "string"}, + "cat": {"type": "token"}, + }, + } +} + + +@pytest.fixture(scope="module") +def facet_collection(engine_client, worker_id): + """A module-scoped collection with a static search index mapping a + text-analyzed field driving the inner operator and a token-mapped field that + is facetable, shared read-only across the facet cases so the index is built + and polled once.""" + db_name = fixtures.generate_database_name("stages_search_facet", worker_id) + fixtures.cleanup_database(engine_client, db_name) + db = engine_client[db_name] + coll = db["facet"] + coll.insert_many(_FACET_DOCS) + create_search_index(coll, _FACET_INDEX_DEFINITION) + yield coll + fixtures.cleanup_database(engine_client, db_name) + + +# Property [Facet Collector Recognition]: the facet collector is recognized in the +# operator slot and executed, returning the documents selected by its inner search +# operator. +SEARCH_FACET_RECOGNITION_TESTS: list[StageTestCase] = [ + StageTestCase( + "facet_collector_executes", + pipeline=[ + { + "$search": { + "facet": { + "operator": {"text": {"query": "quick", "path": "title"}}, + "facets": {"catF": {"type": "string", "path": "cat"}}, + } + } + }, + ], + expected={"cursor.firstBatch": [Len(2), Contains("_id", 1), Contains("_id", 3)]}, + msg="$search should recognize the facet collector and execute it, returning the " + "documents selected by its inner operator", + ), +] + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCH_FACET_RECOGNITION_TESTS)) +def test_search_facet_recognition(facet_collection, test_case: StageTestCase): + """Test $search recognizes and executes the facet collector.""" + result = execute_command( + facet_collection, + {"aggregate": facet_collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult( + result, + expected=test_case.expected, + msg=test_case.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_geo.py b/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_geo.py new file mode 100644 index 000000000..e48b5b913 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_geo.py @@ -0,0 +1,606 @@ +"""Tests for the $search geoWithin and geoShape operators.""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.stages.search.utils.search_common import ( + create_search_index, +) +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, +) +from documentdb_tests.framework import fixtures +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + UNKNOWN_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import ( + Contains, + Len, +) +from documentdb_tests.framework.test_constants import ( + DOUBLE_ZERO, +) + +pytestmark = pytest.mark.requires(search=True) + + +_GEO_DOCS = [ + { + "_id": 1, + "loc": {"type": "Point", "coordinates": [DOUBLE_ZERO, DOUBLE_ZERO]}, + "shaped": {"type": "Point", "coordinates": [DOUBLE_ZERO, DOUBLE_ZERO]}, + }, + { + "_id": 2, + "loc": {"type": "Point", "coordinates": [1.0, 1.0]}, + "shaped": {"type": "Point", "coordinates": [1.0, 1.0]}, + }, + { + "_id": 3, + "loc": {"type": "Point", "coordinates": [5.0, 5.0]}, + "shaped": {"type": "Point", "coordinates": [5.0, 5.0]}, + }, + { + "_id": 4, + "loc": {"type": "Point", "coordinates": [10.0, 10.0]}, + "shaped": {"type": "Point", "coordinates": [10.0, 10.0]}, + }, + { + "_id": 5, + "loc": {"type": "Point", "coordinates": [-3.0, -3.0]}, + "shaped": {"type": "Point", "coordinates": [-3.0, -3.0]}, + }, +] + +_GEO_INDEX_DEFINITION = { + "mappings": { + "dynamic": False, + "fields": { + "loc": {"type": "geo"}, + "shaped": {"type": "geo", "indexShapes": True}, + }, + } +} + + +@pytest.fixture(scope="module") +def geo_collection(engine_client, worker_id): + """A module-scoped collection with a static search index mapping a geo-typed + field, shared read-only across the geoWithin cases so the index is built and + polled once.""" + db_name = fixtures.generate_database_name("stages_search_geo", worker_id) + fixtures.cleanup_database(engine_client, db_name) + db = engine_client[db_name] + coll = db["geo"] + coll.insert_many(_GEO_DOCS) + create_search_index(coll, _GEO_INDEX_DEFINITION) + yield coll + fixtures.cleanup_database(engine_client, db_name) + + +# Property [GeoWithin Region Matching]: geoWithin selects exactly the documents +# whose stored point lies inside the requested box or circle region. +SEARCH_GEO_WITHIN_TESTS: list[StageTestCase] = [ + StageTestCase( + "geo_within_box", + pipeline=[ + { + "$search": { + "geoWithin": { + "path": "loc", + "box": { + "bottomLeft": {"type": "Point", "coordinates": [-1.0, -1.0]}, + "topRight": {"type": "Point", "coordinates": [6.0, 6.0]}, + }, + } + } + }, + ], + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 1), + Contains("_id", 2), + Contains("_id", 3), + ] + }, + msg="$search geoWithin should match the documents whose point lies inside the box region", + ), + StageTestCase( + "geo_within_score_boost", + pipeline=[ + { + "$search": { + "geoWithin": { + "path": "loc", + "box": { + "bottomLeft": {"type": "Point", "coordinates": [-1.0, -1.0]}, + "topRight": {"type": "Point", "coordinates": [6.0, 6.0]}, + }, + "score": {"boost": {"value": 2.0}}, + } + } + }, + ], + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 1), + Contains("_id", 2), + Contains("_id", 3), + ] + }, + msg="$search geoWithin should accept a score modifier and still return its matches", + ), + StageTestCase( + "geo_within_circle", + pipeline=[ + { + "$search": { + "geoWithin": { + "path": "loc", + "circle": { + "center": { + "type": "Point", + "coordinates": [DOUBLE_ZERO, DOUBLE_ZERO], + }, + "radius": 200_000, + }, + } + } + }, + ], + expected={"cursor.firstBatch": [Len(2), Contains("_id", 1), Contains("_id", 2)]}, + msg="$search geoWithin should match the documents whose point lies inside the circle " + "region", + ), + StageTestCase( + "geo_within_geometry_polygon", + pipeline=[ + { + "$search": { + "geoWithin": { + "path": "loc", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-1.0, -1.0], + [-1.0, 3.0], + [3.0, 3.0], + [3.0, -1.0], + [-1.0, -1.0], + ] + ], + }, + } + } + }, + ], + expected={"cursor.firstBatch": [Len(2), Contains("_id", 1), Contains("_id", 2)]}, + msg="$search geoWithin should match the documents whose point lies inside the geometry " + "polygon region", + ), +] + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCH_GEO_WITHIN_TESTS)) +def test_search_geo_within_cases(geo_collection, test_case: StageTestCase): + """Test $search geoWithin box and circle region matching over a geo-mapped path.""" + result = execute_command( + geo_collection, + {"aggregate": geo_collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult( + result, + expected=test_case.expected, + msg=test_case.msg, + raw_res=True, + ) + + +# Property [GeoShape Region Matching]: over a geo path indexed with +# indexShapes=true, geoShape selects exactly the documents whose stored point +# satisfies the requested relation to the geometry. +SEARCH_GEO_SHAPE_MATCH_TESTS: list[StageTestCase] = [ + StageTestCase( + "geo_shape_within_polygon", + pipeline=[ + { + "$search": { + "geoShape": { + "path": "shaped", + "relation": "within", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-1.0, -1.0], + [-1.0, 3.0], + [3.0, 3.0], + [3.0, -1.0], + [-1.0, -1.0], + ] + ], + }, + } + } + }, + ], + expected={"cursor.firstBatch": [Len(2), Contains("_id", 1), Contains("_id", 2)]}, + msg="$search geoShape within should match the points inside the polygon and exclude " + "those outside it", + ), + StageTestCase( + "geo_shape_score_boost", + pipeline=[ + { + "$search": { + "geoShape": { + "path": "shaped", + "relation": "within", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-1.0, -1.0], + [-1.0, 3.0], + [3.0, 3.0], + [3.0, -1.0], + [-1.0, -1.0], + ] + ], + }, + "score": {"boost": {"value": 2.0}}, + } + } + }, + ], + expected={"cursor.firstBatch": [Len(2), Contains("_id", 1), Contains("_id", 2)]}, + msg="$search geoShape should accept a score modifier and still return its matches", + ), + StageTestCase( + "geo_shape_intersects_polygon", + pipeline=[ + { + "$search": { + "geoShape": { + "path": "shaped", + "relation": "intersects", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-1.0, -1.0], + [-1.0, 3.0], + [3.0, 3.0], + [3.0, -1.0], + [-1.0, -1.0], + ] + ], + }, + } + } + }, + ], + expected={"cursor.firstBatch": [Len(2), Contains("_id", 1), Contains("_id", 2)]}, + msg="$search geoShape intersects should match the points that intersect the polygon", + ), + StageTestCase( + "geo_shape_disjoint_polygon", + pipeline=[ + { + "$search": { + "geoShape": { + "path": "shaped", + "relation": "disjoint", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-1.0, -1.0], + [-1.0, 3.0], + [3.0, 3.0], + [3.0, -1.0], + [-1.0, -1.0], + ] + ], + }, + } + } + }, + ], + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 3), + Contains("_id", 4), + Contains("_id", 5), + ] + }, + msg="$search geoShape disjoint should match the points that do not intersect the polygon", + ), +] + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCH_GEO_SHAPE_MATCH_TESTS)) +def test_search_geo_shape_cases(geo_collection, test_case: StageTestCase): + """Test $search geoShape region matching over an indexShapes geo-mapped path.""" + result = execute_command( + geo_collection, + {"aggregate": geo_collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult( + result, + expected=test_case.expected, + msg=test_case.msg, + raw_res=True, + ) + + +# Property [GeoWithin Coordinate Validation]: geoWithin validates shape +# coordinates before the index check. +SEARCH_GEO_WITHIN_COORDINATE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "geo_within_invalid_latitude", + pipeline=[ + { + "$search": { + "geoWithin": { + "path": "loc", + "box": { + "bottomLeft": {"type": "Point", "coordinates": [DOUBLE_ZERO, 91.0]}, + "topRight": {"type": "Point", "coordinates": [6.0, 6.0]}, + }, + } + } + }, + ], + error_code=UNKNOWN_ERROR, + msg="$search geoWithin should reject a latitude outside the valid range", + ), + StageTestCase( + "geo_within_invalid_longitude", + pipeline=[ + { + "$search": { + "geoWithin": { + "path": "loc", + "box": { + "bottomLeft": { + "type": "Point", + "coordinates": [181.0, DOUBLE_ZERO], + }, + "topRight": {"type": "Point", "coordinates": [6.0, 6.0]}, + }, + } + } + }, + ], + error_code=UNKNOWN_ERROR, + msg="$search geoWithin should reject a longitude outside the valid range", + ), + StageTestCase( + "geo_within_negative_radius", + pipeline=[ + { + "$search": { + "geoWithin": { + "path": "loc", + "circle": { + "center": { + "type": "Point", + "coordinates": [DOUBLE_ZERO, DOUBLE_ZERO], + }, + "radius": -1, + }, + } + } + }, + ], + error_code=UNKNOWN_ERROR, + msg="$search geoWithin should reject a negative circle radius", + ), + StageTestCase( + "geo_within_coordinate_too_few_numbers", + pipeline=[ + { + "$search": { + "geoWithin": { + "path": "loc", + "box": { + "bottomLeft": {"type": "Point", "coordinates": [DOUBLE_ZERO]}, + "topRight": {"type": "Point", "coordinates": [6.0, 6.0]}, + }, + } + } + }, + ], + error_code=UNKNOWN_ERROR, + msg="$search geoWithin should reject a coordinate with fewer than two numbers", + ), +] + +# Property [GeoWithin Shape Required]: a geoWithin with no shape key produces an +# error. +SEARCH_GEO_WITHIN_SHAPE_REQUIRED_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "geo_within_no_shape", + pipeline=[{"$search": {"geoWithin": {"path": "loc"}}}], + error_code=UNKNOWN_ERROR, + msg="$search geoWithin should reject a spec with no shape key", + ), +] + +# Property [GeoWithin Geo Index Required]: geoWithin against a path that is not +# indexed as a geo field produces an error. +SEARCH_GEO_WITHIN_INDEX_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "geo_within_path_not_geo_indexed", + pipeline=[ + { + "$search": { + "geoWithin": { + "path": "region", + "box": { + "bottomLeft": { + "type": "Point", + "coordinates": [DOUBLE_ZERO, DOUBLE_ZERO], + }, + "topRight": {"type": "Point", "coordinates": [5.0, 5.0]}, + }, + } + } + }, + ], + error_code=UNKNOWN_ERROR, + msg="$search geoWithin should reject a path not indexed as a geo field", + ), +] + +SEARCH_GEO_WITHIN_ERROR_TESTS = ( + SEARCH_GEO_WITHIN_COORDINATE_ERROR_TESTS + + SEARCH_GEO_WITHIN_SHAPE_REQUIRED_ERROR_TESTS + + SEARCH_GEO_WITHIN_INDEX_ERROR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCH_GEO_WITHIN_ERROR_TESTS)) +def test_search_geo_within_errors(geo_collection, test_case: StageTestCase): + """Test $search geoWithin rejects invalid coordinates and a missing shape key.""" + result = execute_command( + geo_collection, + {"aggregate": geo_collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult(result, error_code=test_case.error_code, msg=test_case.msg) + + +# Property [GeoShape Geometry Validation]: geoShape validates the relation enum +# and geometry shape before the index check. +SEARCH_GEO_SHAPE_GEOMETRY_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "geo_shape_invalid_relation", + pipeline=[ + { + "$search": { + "geoShape": { + "path": "loc", + "relation": "bogus", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [DOUBLE_ZERO, DOUBLE_ZERO], + [DOUBLE_ZERO, 5.0], + [5.0, 5.0], + [5.0, DOUBLE_ZERO], + [DOUBLE_ZERO, DOUBLE_ZERO], + ] + ], + }, + } + } + }, + ], + error_code=UNKNOWN_ERROR, + msg="$search geoShape should reject a relation outside the allowed set", + ), + StageTestCase( + "geo_shape_polygon_too_few_positions", + pipeline=[ + { + "$search": { + "geoShape": { + "path": "loc", + "relation": "intersects", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [DOUBLE_ZERO, DOUBLE_ZERO], + [DOUBLE_ZERO, 5.0], + [DOUBLE_ZERO, DOUBLE_ZERO], + ] + ], + }, + } + } + }, + ], + error_code=UNKNOWN_ERROR, + msg="$search geoShape should reject a polygon with fewer than four positions", + ), + StageTestCase( + "geo_shape_within_with_point", + pipeline=[ + { + "$search": { + "geoShape": { + "path": "loc", + "relation": "within", + "geometry": { + "type": "Point", + "coordinates": [DOUBLE_ZERO, DOUBLE_ZERO], + }, + } + } + }, + ], + error_code=UNKNOWN_ERROR, + msg="$search geoShape should reject the within relation applied to a Point geometry", + ), +] + +# Property [GeoShape IndexShapes Requirement]: geoShape against a geo path not +# indexed with indexShapes=true produces an error. +SEARCH_GEO_SHAPE_INDEX_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "geo_shape_path_not_index_shapes", + pipeline=[ + { + "$search": { + "geoShape": { + "path": "loc", + "relation": "intersects", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [DOUBLE_ZERO, DOUBLE_ZERO], + [DOUBLE_ZERO, 5.0], + [5.0, 5.0], + [5.0, DOUBLE_ZERO], + [DOUBLE_ZERO, DOUBLE_ZERO], + ] + ], + }, + } + } + }, + ], + error_code=UNKNOWN_ERROR, + msg="$search geoShape should reject a geo path not indexed with indexShapes=true", + ), +] + +SEARCH_GEO_SHAPE_ERROR_TESTS = ( + SEARCH_GEO_SHAPE_GEOMETRY_ERROR_TESTS + SEARCH_GEO_SHAPE_INDEX_ERROR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCH_GEO_SHAPE_ERROR_TESTS)) +def test_search_geo_shape_errors(geo_collection, test_case: StageTestCase): + """Test $search geoShape rejects invalid geometry and a non-indexShapes geo path.""" + result = execute_command( + geo_collection, + {"aggregate": geo_collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult(result, error_code=test_case.error_code, msg=test_case.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_highlight.py b/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_highlight.py new file mode 100644 index 000000000..600afa7ff --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_highlight.py @@ -0,0 +1,253 @@ +"""Tests for the $search highlight option and output.""" + +from __future__ import annotations + +import datetime + +import pytest +from bson import Binary, Code, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + UNKNOWN_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import ( + Contains, + Eq, + Gt, + Len, +) +from documentdb_tests.framework.test_constants import ( + DECIMAL128_ONE_AND_HALF, +) + +pytestmark = pytest.mark.requires(search=True) + + +# Property [Highlight Path Forms]: highlight.path accepts a {wildcard} document +# and an array of paths (the string form is owned by the searchHighlights output +# property). +SEARCH_HIGHLIGHT_PATH_FORM_TESTS: list[StageTestCase] = [ + StageTestCase( + "highlight_path_wildcard", + pipeline=[ + { + "$search": { + "text": {"query": "quick", "path": "title"}, + "highlight": {"path": {"wildcard": "*"}}, + } + }, + ], + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 1), + Contains("_id", 3), + Contains("_id", 4), + ] + }, + msg="$search should accept a {wildcard} highlight.path and still return its matches", + ), + StageTestCase( + "highlight_path_array", + pipeline=[ + { + "$search": { + "text": {"query": "quick", "path": "title"}, + "highlight": {"path": ["title", "body"]}, + } + }, + ], + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 1), + Contains("_id", 3), + Contains("_id", 4), + ] + }, + msg="$search should accept an array highlight.path and still return its matches", + ), +] + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCH_HIGHLIGHT_PATH_FORM_TESTS)) +def test_search_highlight_path_cases(indexed_collection, test_case: StageTestCase): + """Test $search highlight path forms.""" + result = execute_command( + indexed_collection, + {"aggregate": indexed_collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult( + result, + expected=test_case.expected, + msg=test_case.msg, + raw_res=True, + ) + + +# Property [SearchHighlights Output]: with highlight enabled, {$meta: +# "searchHighlights"} projects per-path entries that split matched tokens into +# "hit" spans and surrounding context into "text" spans, and a multi-byte matched +# token is highlighted intact as a single hit span with no offset corruption. +SEARCH_HIGHLIGHT_TESTS: list[StageTestCase] = [ + StageTestCase( + "highlight_ascii_hit_and_text_spans", + pipeline=[ + { + "$search": { + "text": {"query": "quick", "path": "title"}, + "highlight": {"path": "title"}, + } + }, + {"$limit": 1}, + {"$project": {"_id": 0, "hl": {"$meta": "searchHighlights"}}}, + ], + expected={ + "hl.0.path": Eq("title"), + "hl.0.score": Gt(0), + "hl.0.texts": [ + Contains("type", "hit"), + Contains("type", "text"), + Contains("value", "quick"), + ], + }, + msg="$search should tag matched tokens as hit spans and surrounding context as " + "text spans", + ), + StageTestCase( + "highlight_multibyte_intact_span", + pipeline=[ + { + "$search": { + "text": {"query": "résumé", "path": "title"}, + "highlight": {"path": "title"}, + } + }, + {"$limit": 1}, + {"$project": {"_id": 0, "hl": {"$meta": "searchHighlights"}}}, + ], + expected={ + "hl.0.path": Eq("title"), + "hl.0.score": Gt(0), + "hl.0.texts": [Contains("type", "hit"), Contains("value", "résumé")], + }, + msg="$search should highlight a multi-byte token intact as a single hit span " + "without offset corruption", + ), +] + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCH_HIGHLIGHT_TESTS)) +def test_search_highlights(indexed_collection, test_case: StageTestCase): + """Test $search projects searchHighlights spans tagging hits and context.""" + result = execute_command( + indexed_collection, + {"aggregate": indexed_collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult(result, expected=test_case.expected, msg=test_case.msg) + + +# Property [Highlight Sub-field Validation]: highlight requires a path, rejects a +# path of an unaccepted type (anything but a string, document, or array of paths), +# and rejects an unknown sub-field. +SEARCH_HIGHLIGHT_SUBFIELD_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "highlight_missing_path", + pipeline=[ + {"$search": {"text": {"query": "quick", "path": "title"}, "highlight": {}}}, + ], + error_code=UNKNOWN_ERROR, + msg="$search should reject a highlight document missing the required path", + ), + *[ + StageTestCase( + f"highlight_path_{tid}", + pipeline=[ + { + "$search": { + "text": {"query": "quick", "path": "title"}, + "highlight": {"path": val}, + } + }, + ], + error_code=UNKNOWN_ERROR, + msg=f"$search should reject a {tid} highlight.path as neither a string, document, " + "nor array", + ) + for tid, val in [ + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("bool", True), + ("objectid", ObjectId("0123456789abcdef01234567")), + ("datetime", datetime.datetime(2020, 1, 1)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ] + ], + StageTestCase( + "highlight_unknown_subfield", + pipeline=[ + { + "$search": { + "text": {"query": "quick", "path": "title"}, + "highlight": {"path": "title", "bogus": 1}, + } + }, + ], + error_code=UNKNOWN_ERROR, + msg="$search should reject an unknown highlight sub-field", + ), +] + +# Property [Highlight Integer Bounds]: highlight.maxCharsToExamine and +# highlight.maxNumPassages must each be positive - a tighter bound than the +# non-negative phrase.slop, which accepts zero. +SEARCH_HIGHLIGHT_INTEGER_BOUNDS_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"highlight_{opt_id}_{tid}", + pipeline=[ + { + "$search": { + "text": {"query": "quick", "path": "title"}, + "highlight": {"path": "title", opt: val}, + } + }, + ], + error_code=UNKNOWN_ERROR, + msg=f"$search should reject a {tid} highlight.{opt} as non-positive", + ) + for opt, opt_id in [ + ("maxCharsToExamine", "max_chars"), + ("maxNumPassages", "max_passages"), + ] + for tid, val in [("zero", 0), ("negative", -1)] +] + +SEARCH_HIGHLIGHT_ERROR_TESTS = ( + SEARCH_HIGHLIGHT_SUBFIELD_ERROR_TESTS + SEARCH_HIGHLIGHT_INTEGER_BOUNDS_ERROR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCH_HIGHLIGHT_ERROR_TESTS)) +def test_search_highlight_errors(indexed_collection, test_case: StageTestCase): + """Test $search highlight subfield and integer-bounds validation errors.""" + result = execute_command( + indexed_collection, + {"aggregate": indexed_collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult(result, error_code=test_case.error_code, msg=test_case.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_in.py b/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_in.py new file mode 100644 index 000000000..1bf902e2e --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_in.py @@ -0,0 +1,485 @@ +"""Tests for the $search in operator.""" + +from __future__ import annotations + +import datetime +import uuid + +import pytest +from bson import Binary, Code, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.stages.search.utils.search_common import ( + QUERY_CLAUSE_CAP, + create_search_index, +) +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, +) +from documentdb_tests.framework import fixtures +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + UNKNOWN_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import ( + Contains, + Len, +) +from documentdb_tests.framework.test_constants import ( + DECIMAL128_ONE_AND_HALF, + DOUBLE_MAX_SAFE_INTEGER, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_PRECISION_LOSS, + DOUBLE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, +) + +pytestmark = pytest.mark.requires(search=True) + + +_IN_OBJECT_ID = ObjectId("0123456789abcdef01234567") + +_IN_UUID = uuid.UUID("12345678-1234-4567-8901-123456789abc") + +_IN_DATE = datetime.datetime(2020, 1, 1) + +_IN_DOCS = [ + {"_id": 1, "num": 20}, # int32 + {"_id": 2, "num": Int64(20)}, # int64 + {"_id": 3, "num": 20.0}, # double + {"_id": 4, "num": 5}, # int32, distinct value + {"_id": 5, "big": Int64(DOUBLE_PRECISION_LOSS)}, # 2^53+1 (no exact double) + {"_id": 6, "big": float(DOUBLE_MAX_SAFE_INTEGER)}, # 2^53 + {"_id": 7, "zero": DOUBLE_ZERO}, + {"_id": 8, "zero": DOUBLE_NEGATIVE_ZERO}, + {"_id": 9, "nf": FLOAT_NAN}, + {"_id": 10, "nf": FLOAT_INFINITY}, + {"_id": 11, "nf": FLOAT_NEGATIVE_INFINITY}, + {"_id": 12, "b": True}, + {"_id": 13, "oid": _IN_OBJECT_ID}, + {"_id": 14, "dt": _IN_DATE}, + {"_id": 15, "s": "apple"}, # stored on a token-mapped path + {"_id": 16, "u": Binary.from_uuid(_IN_UUID)}, +] + +_IN_INDEX_DEFINITION = { + "mappings": { + "dynamic": False, + "fields": { + "num": {"type": "number"}, + "big": {"type": "number"}, + "zero": {"type": "number"}, + "nf": {"type": "number"}, + "b": {"type": "boolean"}, + "oid": {"type": "objectId"}, + "dt": {"type": "date"}, + "s": {"type": "token"}, + "u": {"type": "uuid"}, + }, + } +} + + +@pytest.fixture(scope="module") +def in_collection(engine_client, worker_id): + """A module-scoped collection with a static search index mapping four + number-typed fields (mixed numeric representations, a lossy-double pair, a + signed-zero pair, and the non-finite doubles) plus one field of each other + supported value type (boolean, objectId, date, token string, and uuid), + shared read-only across the in cases so the index is built and polled once.""" + db_name = fixtures.generate_database_name("stages_search_in", worker_id) + fixtures.cleanup_database(engine_client, db_name) + db = engine_client[db_name] + coll = db["in_op"] + coll.insert_many(_IN_DOCS) + create_search_index(coll, _IN_INDEX_DEFINITION) + yield coll + fixtures.cleanup_database(engine_client, db_name) + + +# Property [In Value Or Array]: in.value accepts both a single scalar and an +# array, each selecting the documents storing a value equal to a listed value. +SEARCH_IN_VALUE_OR_ARRAY_TESTS: list[StageTestCase] = [ + StageTestCase( + "in_single_scalar", + pipeline=[{"$search": {"in": {"path": "num", "value": 20}}}], + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 1), + Contains("_id", 2), + Contains("_id", 3), + ] + }, + msg="$search in should accept a single scalar value and match the documents storing it", + ), + StageTestCase( + "in_score_boost", + pipeline=[ + {"$search": {"in": {"path": "num", "value": 20, "score": {"boost": {"value": 2.0}}}}}, + ], + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 1), + Contains("_id", 2), + Contains("_id", 3), + ] + }, + msg="$search in should accept a score modifier and still return its matches", + ), + StageTestCase( + "in_array_single_element", + pipeline=[{"$search": {"in": {"path": "num", "value": [20]}}}], + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 1), + Contains("_id", 2), + Contains("_id", 3), + ] + }, + msg="$search in should accept an array value and match the documents storing a " + "listed value", + ), +] + +# Property [In Value-Type Acceptance]: like equals, in matches each supported +# non-numeric value type, selecting the document storing a listed value. +SEARCH_IN_VALUE_TYPE_ACCEPTANCE_TESTS: list[StageTestCase] = [ + StageTestCase( + "in_value_type_bool", + pipeline=[{"$search": {"in": {"path": "b", "value": [True]}}}], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 12)]}, + msg="$search in should match the document storing a listed boolean value", + ), + StageTestCase( + "in_value_type_objectid", + pipeline=[{"$search": {"in": {"path": "oid", "value": [_IN_OBJECT_ID]}}}], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 13)]}, + msg="$search in should match the document storing a listed ObjectId value", + ), + StageTestCase( + "in_value_type_date", + pipeline=[{"$search": {"in": {"path": "dt", "value": [_IN_DATE]}}}], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 14)]}, + msg="$search in should match the document storing a listed date value", + ), + StageTestCase( + "in_value_type_string", + pipeline=[{"$search": {"in": {"path": "s", "value": ["apple"]}}}], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 15)]}, + msg="$search in should match the document storing a listed string value on a " + "token-mapped path", + ), + StageTestCase( + "in_value_type_uuid", + pipeline=[{"$search": {"in": {"path": "u", "value": [Binary.from_uuid(_IN_UUID)]}}}], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 16)]}, + msg="$search in should match the document storing a listed UUID value (Binary subtype 4)", + ), +] + +# Property [In Mixed-Numeric Homogeneity]: in treats int32/int64/double as one +# type, so a mixed-numeric array is accepted and matches every stored numeric +# representation that narrows to a listed value's double, with -0.0 equal to 0.0. +SEARCH_IN_MIXED_NUMERIC_TESTS: list[StageTestCase] = [ + StageTestCase( + "in_mixed_distinct_values", + pipeline=[ + {"$search": {"in": {"path": "num", "value": [5, 20.0]}}}, + ], + expected={ + "cursor.firstBatch": [ + Len(4), + Contains("_id", 1), + Contains("_id", 2), + Contains("_id", 3), + Contains("_id", 4), + ] + }, + msg="$search in should accept a mixed-numeric array and match the union of its " + "listed values", + ), + StageTestCase( + "in_mixed_int64_and_double", + pipeline=[ + {"$search": {"in": {"path": "num", "value": [Int64(20), 20.0]}}}, + ], + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 1), + Contains("_id", 2), + Contains("_id", 3), + ] + }, + msg="$search in should treat int64 and double as one type in a mixed array and match " + "every numeric representation of the listed value", + ), + StageTestCase( + "in_lossy_double_equality", + pipeline=[ + {"$search": {"in": {"path": "big", "value": [Int64(DOUBLE_PRECISION_LOSS)]}}}, + ], + expected={"cursor.firstBatch": [Len(2), Contains("_id", 5), Contains("_id", 6)]}, + msg="$search in should compare in double space, matching both stored representations " + "that narrow to the same double", + ), + StageTestCase( + "in_negative_zero_positive", + pipeline=[ + {"$search": {"in": {"path": "zero", "value": [DOUBLE_ZERO]}}}, + ], + expected={"cursor.firstBatch": [Len(2), Contains("_id", 7), Contains("_id", 8)]}, + msg="$search in with positive-zero should match both stored 0.0 and -0.0", + ), + StageTestCase( + "in_negative_zero_negative", + pipeline=[ + {"$search": {"in": {"path": "zero", "value": [DOUBLE_NEGATIVE_ZERO]}}}, + ], + expected={"cursor.firstBatch": [Len(2), Contains("_id", 7), Contains("_id", 8)]}, + msg="$search in with negative-zero should match both stored 0.0 and -0.0", + ), +] + +# Property [In No Clause Cap]: in imposes no clause cap, so query arrays sized at +# and one past the text.query clause cap are both accepted. +SEARCH_IN_CLAUSE_CAP_TESTS: list[StageTestCase] = [ + StageTestCase( + "in_clause_count_1024", + pipeline=[ + { + "$search": { + "in": { + "path": "num", + "value": [20] + list(range(1000, 1000 + QUERY_CLAUSE_CAP - 1)), + } + } + }, + ], + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 1), + Contains("_id", 2), + Contains("_id", 3), + ] + }, + msg="$search in should accept a value array sized at the text.query clause cap " + "with no clause cap of its own", + ), + StageTestCase( + "in_clause_count_1025", + pipeline=[ + { + "$search": { + "in": { + "path": "num", + "value": [20] + list(range(1000, 1000 + QUERY_CLAUSE_CAP)), + } + } + }, + ], + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 1), + Contains("_id", 2), + Contains("_id", 3), + ] + }, + msg="$search in should accept a value array one past the text.query clause cap", + ), +] + +# Property [In Non-Finite Doubles]: in matches a stored NaN, +inf, or -inf when +# that non-finite double is listed, the opposite of equals which never matches +# them. +SEARCH_IN_NON_FINITE_TESTS: list[StageTestCase] = [ + StageTestCase( + "in_nan", + pipeline=[ + {"$search": {"in": {"path": "nf", "value": [FLOAT_NAN]}}}, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 9)]}, + msg="$search in should match a stored NaN, unlike equals", + ), + StageTestCase( + "in_positive_infinity", + pipeline=[ + {"$search": {"in": {"path": "nf", "value": [FLOAT_INFINITY]}}}, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 10)]}, + msg="$search in should match a stored +inf, unlike equals", + ), + StageTestCase( + "in_negative_infinity", + pipeline=[ + {"$search": {"in": {"path": "nf", "value": [FLOAT_NEGATIVE_INFINITY]}}}, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 11)]}, + msg="$search in should match a stored -inf, unlike equals", + ), +] + +# Property [In doesNotAffect Option]: in recognizes a string doesNotAffect option +# (unlike text or near, which reject the field), accepting it and still returning +# its matches. +SEARCH_IN_DOES_NOT_AFFECT_TESTS: list[StageTestCase] = [ + StageTestCase( + "in_does_not_affect_string", + pipeline=[{"$search": {"in": {"path": "b", "value": [True], "doesNotAffect": "score"}}}], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 12)]}, + msg="$search in should accept a string doesNotAffect option and still return its matches", + ), +] + +SEARCH_IN_TESTS = ( + SEARCH_IN_VALUE_OR_ARRAY_TESTS + + SEARCH_IN_VALUE_TYPE_ACCEPTANCE_TESTS + + SEARCH_IN_MIXED_NUMERIC_TESTS + + SEARCH_IN_CLAUSE_CAP_TESTS + + SEARCH_IN_NON_FINITE_TESTS + + SEARCH_IN_DOES_NOT_AFFECT_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCH_IN_TESTS)) +def test_search_in_cases(in_collection, test_case: StageTestCase): + """Test $search in value semantics over a number-mapped path.""" + result = execute_command( + in_collection, + {"aggregate": in_collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult( + result, + expected=test_case.expected, + msg=test_case.msg, + raw_res=True, + ) + + +# Property [In Required Fields]: in.path and in.value are both required, so a +# spec omitting either is rejected. +SEARCH_IN_REQUIRED_FIELD_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "in_path_missing", + pipeline=[{"$search": {"in": {"value": [20]}}}], + error_code=UNKNOWN_ERROR, + msg="$search in should reject a spec that omits the required path", + ), + StageTestCase( + "in_value_missing", + pipeline=[{"$search": {"in": {"path": "num"}}}], + error_code=UNKNOWN_ERROR, + msg="$search in should reject a spec that omits the required value", + ), +] + +# Property [In doesNotAffect Type]: the doesNotAffect option must be a string, so +# a non-string is rejected. +SEARCH_IN_DOES_NOT_AFFECT_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "in_does_not_affect_non_string", + pipeline=[{"$search": {"in": {"path": "num", "value": [20], "doesNotAffect": 1}}}], + error_code=UNKNOWN_ERROR, + msg="$search in should reject a non-string doesNotAffect option", + ), +] + +# Property [In Empty Value Array]: an empty in.value array is rejected as it lists +# no value to match. +SEARCH_IN_EMPTY_VALUE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "in_empty_value_array", + pipeline=[{"$search": {"in": {"path": "num", "value": []}}}], + error_code=UNKNOWN_ERROR, + msg="$search in should reject an empty value array", + ), +] + +# Property [In Null Element]: a null element in the in.value array is rejected, +# unlike equals which accepts null. +SEARCH_IN_NULL_ELEMENT_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "in_null_element", + pipeline=[{"$search": {"in": {"path": "num", "value": [None]}}}], + error_code=UNKNOWN_ERROR, + msg="$search in should reject a null element in the value array", + ), +] + +# Property [In Element Homogeneity]: every in.value element must share one type +# category, so an array mixing distinct categories is rejected (numeric subtypes +# count as one category). +SEARCH_IN_MIXED_TYPE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "in_mixed_number_string", + pipeline=[ + {"$search": {"in": {"path": "num", "value": [20, "apple"]}}}, + ], + error_code=UNKNOWN_ERROR, + msg="$search in should reject an array mixing a number and a string", + ), +] + +# Property [In Element Value Type Dispatch]: in dispatches each in.value array +# element through the same value-type validator as equals.value, so a Binary +# element is accepted only as a UUID (subtype 4). +SEARCH_IN_VALUE_TYPE_ERROR_TESTS: list[StageTestCase] = [ + *[ + StageTestCase( + f"in_value_type_{tid}", + pipeline=[ + {"$search": {"in": {"path": "num", "value": [val]}}}, + ], + error_code=UNKNOWN_ERROR, + msg=f"$search in should route a {tid} element through the value-type validator " + "and reject it", + ) + for tid, val in [ + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("timestamp", Timestamp(1, 1)), + ("object", {"a": 1}), + ("nested_array", [1, 2]), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] + ], + StageTestCase( + "in_value_type_binary_non_uuid", + pipeline=[ + {"$search": {"in": {"path": "u", "value": [Binary(b"\x01\x02\x03")]}}}, + ], + error_code=UNKNOWN_ERROR, + msg="$search in should reject a Binary element that is not UUID subtype 4", + ), +] + +SEARCH_IN_ERROR_TESTS = ( + SEARCH_IN_REQUIRED_FIELD_ERROR_TESTS + + SEARCH_IN_DOES_NOT_AFFECT_ERROR_TESTS + + SEARCH_IN_EMPTY_VALUE_ERROR_TESTS + + SEARCH_IN_NULL_ELEMENT_ERROR_TESTS + + SEARCH_IN_MIXED_TYPE_ERROR_TESTS + + SEARCH_IN_VALUE_TYPE_ERROR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCH_IN_ERROR_TESTS)) +def test_search_in_errors(in_collection, test_case: StageTestCase): + """Test $search in rejects empty, null, mixed-type, and unsupported-type values.""" + result = execute_command( + in_collection, + {"aggregate": in_collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult(result, error_code=test_case.error_code, msg=test_case.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_moreLikeThis.py b/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_moreLikeThis.py new file mode 100644 index 000000000..fdf43a1b5 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_moreLikeThis.py @@ -0,0 +1,73 @@ +"""Tests for the $search moreLikeThis operator.""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import UNKNOWN_ERROR +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Contains, Len + +pytestmark = pytest.mark.requires(search=True) + + +# Property [moreLikeThis Similarity]: a like seed document returns the documents +# that share indexed terms with it, and excludes documents that share none. +SEARCH_MORELIKETHIS_SUCCESS_TESTS: list[StageTestCase] = [ + StageTestCase( + "morelikethis_like_document", + pipeline=[{"$search": {"moreLikeThis": {"like": {"title": "quick brown fox"}}}}], + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 1), + Contains("_id", 3), + Contains("_id", 4), + ] + }, + msg="$search moreLikeThis should return documents sharing terms with the like seed " + "document", + ), +] + +# Property [moreLikeThis Validation]: moreLikeThis requires a like field that is +# a document (or array of documents), so an omitted or non-document like is +# rejected. +SEARCH_MORELIKETHIS_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "morelikethis_missing_like", + pipeline=[{"$search": {"moreLikeThis": {}}}], + error_code=UNKNOWN_ERROR, + msg="$search moreLikeThis should reject a spec missing the required like field", + ), + StageTestCase( + "morelikethis_like_non_document", + pipeline=[{"$search": {"moreLikeThis": {"like": "quick"}}}], + error_code=UNKNOWN_ERROR, + msg="$search moreLikeThis should reject a non-document like value", + ), +] + +SEARCH_MORELIKETHIS_TESTS = SEARCH_MORELIKETHIS_SUCCESS_TESTS + SEARCH_MORELIKETHIS_ERROR_TESTS + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCH_MORELIKETHIS_TESTS)) +def test_search_moreLikeThis_cases(indexed_collection, test_case: StageTestCase): + """Test $search moreLikeThis similarity matching and validation.""" + result = execute_command( + indexed_collection, + {"aggregate": indexed_collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_near.py b/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_near.py new file mode 100644 index 000000000..a3d4190e9 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_near.py @@ -0,0 +1,443 @@ +"""Tests for the $search near operator.""" + +from __future__ import annotations + +import datetime + +import pytest +from bson import Binary, Code, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.stages.search.utils.search_common import ( + create_search_index, +) +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, +) +from documentdb_tests.framework import fixtures +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + UNKNOWN_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import ( + Eq, + Gt, + Len, + PerDoc, +) +from documentdb_tests.framework.test_constants import ( + DECIMAL128_ONE_AND_HALF, + DOUBLE_FROM_INT64_MAX, + DOUBLE_MAX, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + INT64_MAX, +) + +pytestmark = pytest.mark.requires(search=True) + + +_NEAR_DOCS = [ + {"_id": 1, "num": 0}, + {"_id": 2, "num": 10}, + {"_id": 3, "num": 40}, + {"_id": 4, "dt": datetime.datetime(2020, 1, 1)}, + {"_id": 5, "dt": datetime.datetime(2020, 1, 11)}, + {"_id": 6, "dt": datetime.datetime(2020, 2, 10)}, + {"_id": 7, "loc": {"type": "Point", "coordinates": [DOUBLE_ZERO, DOUBLE_ZERO]}}, + {"_id": 8, "loc": {"type": "Point", "coordinates": [DOUBLE_ZERO, 0.1]}}, + {"_id": 9, "loc": {"type": "Point", "coordinates": [DOUBLE_ZERO, 1.0]}}, + {"_id": 10, "big": INT64_MAX}, + {"_id": 11, "big": DOUBLE_FROM_INT64_MAX}, + {"_id": 12, "big": 5}, +] + +_NEAR_INDEX_DEFINITION = { + "mappings": { + "dynamic": False, + "fields": { + "num": {"type": "number"}, + "dt": {"type": "date"}, + "loc": {"type": "geo"}, + "big": {"type": "number"}, + }, + } +} + + +@pytest.fixture(scope="module") +def near_collection(engine_client, worker_id): + """A module-scoped collection with a static search index mapping a numeric, a + date, a geo, and a second numeric (cross-type) field, shared read-only across + the near cases so the index is built and polled once.""" + db_name = fixtures.generate_database_name("stages_search_near", worker_id) + fixtures.cleanup_database(engine_client, db_name) + db = engine_client[db_name] + coll = db["near_op"] + coll.insert_many(_NEAR_DOCS) + create_search_index(coll, _NEAR_INDEX_DEFINITION) + yield coll + fixtures.cleanup_database(engine_client, db_name) + + +# Property [Near Proximity Ordering]: near orders the documents on the queried +# path by ascending distance from a numeric, date, or geo origin (closest first), +# scoring a document at the exact origin 1.0 and scaling the rest by +# pivot/(pivot+distance). +SEARCH_NEAR_PROXIMITY_TESTS: list[StageTestCase] = [ + StageTestCase( + "near_numeric_proximity", + pipeline=[ + {"$search": {"near": {"path": "num", "origin": 10, "pivot": 10}}}, + {"$project": {"_id": 1, "score": {"$meta": "searchScore"}}}, + ], + expected=PerDoc( + {"_id": Eq(2), "score": Eq(1.0)}, + {"_id": Eq(1), "score": Eq(0.5)}, + {"_id": Eq(3), "score": Eq(0.25)}, + ), + msg="$search near should order numeric results by proximity and score them by " + "pivot/(pivot+distance)", + ), + StageTestCase( + "near_score_boost", + pipeline=[ + { + "$search": { + "near": { + "path": "num", + "origin": 10, + "pivot": 10, + "score": {"boost": {"value": 2.0}}, + } + } + }, + {"$project": {"_id": 1, "score": {"$meta": "searchScore"}}}, + ], + expected=PerDoc( + {"_id": Eq(2), "score": Gt(0)}, + {"_id": Eq(1), "score": Gt(0)}, + {"_id": Eq(3), "score": Gt(0)}, + ), + msg="$search near should accept a score modifier and still order its matches by " + "proximity", + ), + StageTestCase( + "near_date_proximity", + # The pivot is 10 days expressed in milliseconds, the distance unit for a + # date origin, so the docs 10 and 30 days away score 0.5 and 0.25. + pipeline=[ + { + "$search": { + "near": { + "path": "dt", + "origin": datetime.datetime(2020, 1, 11), + "pivot": 864_000_000, + } + } + }, + {"$project": {"_id": 1, "score": {"$meta": "searchScore"}}}, + ], + expected=PerDoc( + {"_id": Eq(5), "score": Eq(1.0)}, + {"_id": Eq(4), "score": Eq(0.5)}, + {"_id": Eq(6), "score": Eq(0.25)}, + ), + msg="$search near should order date results by proximity and score them by " + "pivot/(pivot+distance)", + ), + StageTestCase( + "near_geo_proximity", + pipeline=[ + { + "$search": { + "near": { + "path": "loc", + "origin": {"type": "Point", "coordinates": [DOUBLE_ZERO, DOUBLE_ZERO]}, + "pivot": 10_000, + } + } + }, + {"$project": {"_id": 1, "score": {"$meta": "searchScore"}}}, + ], + expected=PerDoc( + {"_id": Eq(7), "score": Eq(1.0)}, + {"_id": Eq(8), "score": Gt(0)}, + {"_id": Eq(9), "score": Gt(0)}, + ), + msg="$search near should order geo results by geodesic proximity and score a " + "document at the exact origin 1.0", + ), +] + +# Property [Near Pivot Acceptance]: any positive finite pivot is accepted and +# tunes the proximity falloff. +SEARCH_NEAR_PIVOT_TESTS: list[StageTestCase] = [ + StageTestCase( + "near_pivot_fractional", + pipeline=[ + {"$search": {"near": {"path": "num", "origin": 10, "pivot": 0.5}}}, + {"$project": {"_id": 1, "score": {"$meta": "searchScore"}}}, + ], + expected=PerDoc( + {"_id": Eq(2), "score": Eq(1.0)}, + {"_id": Eq(1), "score": Gt(0)}, + {"_id": Eq(3), "score": Gt(0)}, + ), + msg="$search near should accept a fractional pivot and still score the " + "exact-origin document 1.0", + ), + StageTestCase( + "near_pivot_very_large", + pipeline=[ + {"$search": {"near": {"path": "num", "origin": 10, "pivot": DOUBLE_MAX}}}, + {"$project": {"_id": 1, "score": {"$meta": "searchScore"}}}, + ], + expected={"score": Eq(1.0)}, + msg="$search near should accept a very large pivot, saturating every score to 1.0", + ), + StageTestCase( + "near_pivot_very_small", + # A 1e-300 pivot underflows pivot/(pivot+distance) to 0.0 for any nonzero + # distance, so only the exact-origin document keeps a 1.0 score. + pipeline=[ + {"$search": {"near": {"path": "num", "origin": 10, "pivot": 1e-300}}}, + {"$project": {"_id": 1, "score": {"$meta": "searchScore"}}}, + ], + expected=PerDoc( + {"_id": Eq(2), "score": Eq(1.0)}, + {"score": Eq(DOUBLE_ZERO)}, + {"score": Eq(DOUBLE_ZERO)}, + ), + msg="$search near should accept a very small pivot, scoring only the " + "exact-origin document 1.0 and decaying the rest to 0.0", + ), +] + +# Property [Near Numeric Origin Boundaries]: non-finite and extreme numeric +# origins execute without error. +SEARCH_NEAR_ORIGIN_BOUNDARY_TESTS: list[StageTestCase] = [ + StageTestCase( + "near_origin_nan", + pipeline=[ + {"$search": {"near": {"path": "num", "origin": FLOAT_NAN, "pivot": 10}}}, + {"$project": {"_id": 1, "score": {"$meta": "searchScore"}}}, + ], + expected={"score": Eq(DOUBLE_ZERO)}, + msg="$search near should execute a NaN origin with no error, scoring every document 0.0", + ), + StageTestCase( + "near_origin_infinite", + # The sign of an infinite-magnitude origin is erased by the absolute + # distance, so a single infinite-origin case covers both signs. + pipeline=[ + {"$search": {"near": {"path": "num", "origin": FLOAT_INFINITY, "pivot": 10}}}, + {"$project": {"_id": 1, "score": {"$meta": "searchScore"}}}, + ], + expected={"score": Eq(DOUBLE_ZERO)}, + msg="$search near should execute an infinite origin with no error, scoring every " + "document 0.0", + ), + StageTestCase( + "near_origin_double_max", + pipeline=[ + {"$search": {"near": {"path": "num", "origin": DOUBLE_MAX, "pivot": 10}}}, + {"$project": {"_id": 1, "score": {"$meta": "searchScore"}}}, + ], + expected={"score": Eq(DOUBLE_ZERO)}, + msg="$search near should execute a DBL_MAX origin with no error, scoring every " + "document 0.0", + ), + StageTestCase( + "near_origin_int64_max_cross_type", + pipeline=[ + {"$search": {"near": {"path": "big", "origin": INT64_MAX, "pivot": 1}}}, + {"$project": {"_id": 1, "score": {"$meta": "searchScore"}}}, + ], + expected=PerDoc( + {"score": Eq(1.0)}, + {"score": Eq(1.0)}, + {"_id": Eq(12), "score": Gt(0)}, + ), + msg="$search near should score both the int64-max document and its double " + "approximation 1.0 for an int64-max origin", + ), + StageTestCase( + "near_origin_negative_zero", + pipeline=[ + {"$search": {"near": {"path": "num", "origin": DOUBLE_NEGATIVE_ZERO, "pivot": 10}}}, + {"$project": {"_id": 1, "score": {"$meta": "searchScore"}}}, + ], + expected=PerDoc( + {"_id": Eq(1), "score": Eq(1.0)}, + {"_id": Eq(2), "score": Eq(0.5)}, + {"_id": Eq(3), "score": Gt(0)}, + ), + msg="$search near should treat a -0.0 origin identically to 0.0, scoring the " + "zero-valued document 1.0", + ), +] + +SEARCH_NEAR_CASES_TESTS = ( + SEARCH_NEAR_PROXIMITY_TESTS + SEARCH_NEAR_PIVOT_TESTS + SEARCH_NEAR_ORIGIN_BOUNDARY_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCH_NEAR_CASES_TESTS)) +def test_search_near_cases(near_collection, test_case: StageTestCase): + """Test $search near proximity ordering, scoring, pivot, and origin boundaries.""" + result = execute_command( + near_collection, + {"aggregate": near_collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult(result, expected=test_case.expected, msg=test_case.msg) + + +# Property [Near Type-Mismatched Origin Silent No-Match]: a numeric origin against +# a date path returns no documents and no error, unlike the string, null, or +# Decimal128 origins that fail. +SEARCH_NEAR_SILENT_NO_MATCH_TESTS: list[StageTestCase] = [ + StageTestCase( + "near_numeric_origin_date_path", + pipeline=[ + {"$search": {"near": {"path": "dt", "origin": 10, "pivot": 10}}}, + ], + expected={"cursor.firstBatch": Len(0)}, + msg="$search near should return no documents and no error for a numeric origin " + "on a date path", + ), +] + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCH_NEAR_SILENT_NO_MATCH_TESTS)) +def test_search_near_silent_no_match(near_collection, test_case: StageTestCase): + """Test $search near returns a silent empty result for a type-mismatched origin.""" + result = execute_command( + near_collection, + {"aggregate": near_collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult( + result, + expected=test_case.expected, + msg=test_case.msg, + raw_res=True, + ) + + +# Property [Near Pivot Validation]: near.pivot must be a positive finite number, +# so a non-positive, non-finite, or non-number pivot is rejected. +SEARCH_NEAR_PIVOT_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "near_pivot_zero", + pipeline=[ + {"$search": {"near": {"path": "num", "origin": 10, "pivot": 0}}}, + ], + error_code=UNKNOWN_ERROR, + msg="$search near should reject a pivot of zero as not positive", + ), + StageTestCase( + "near_pivot_negative", + pipeline=[ + {"$search": {"near": {"path": "num", "origin": 10, "pivot": -1}}}, + ], + error_code=UNKNOWN_ERROR, + msg="$search near should reject a negative pivot as not positive", + ), + StageTestCase( + "near_pivot_nan", + pipeline=[ + {"$search": {"near": {"path": "num", "origin": 10, "pivot": FLOAT_NAN}}}, + ], + error_code=UNKNOWN_ERROR, + msg="$search near should reject a NaN pivot as not finite", + ), + StageTestCase( + "near_pivot_infinity", + pipeline=[ + {"$search": {"near": {"path": "num", "origin": 10, "pivot": FLOAT_INFINITY}}}, + ], + error_code=UNKNOWN_ERROR, + msg="$search near should reject an infinite pivot as not finite", + ), + StageTestCase( + "near_pivot_non_number", + pipeline=[ + {"$search": {"near": {"path": "num", "origin": 10, "pivot": "ten"}}}, + ], + error_code=UNKNOWN_ERROR, + msg="$search near should reject a non-number pivot", + ), +] + +# Property [Near Origin Type Match]: a near.origin of any type outside the +# supported set (number, date, geo Point) is rejected, unlike a type-mismatched +# numeric origin which silently matches nothing. +SEARCH_NEAR_ORIGIN_TYPE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"near_origin_{tid}", + pipeline=[ + {"$search": {"near": {"path": "num", "origin": val, "pivot": 10}}}, + ], + error_code=UNKNOWN_ERROR, + msg=f"$search near should reject a {tid} origin as an unsupported type", + ) + for tid, val in [ + ("string", "ten"), + ("bool", True), + ("array", [1, 2]), + ("objectid", ObjectId("0123456789abcdef01234567")), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("null", None), + ] +] + +# Property [Near Required Fields]: near.path, near.origin, and near.pivot are all +# required, so a spec omitting any one of them is rejected. +SEARCH_NEAR_REQUIRED_FIELD_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "near_path_missing", + pipeline=[{"$search": {"near": {"origin": 10, "pivot": 10}}}], + error_code=UNKNOWN_ERROR, + msg="$search near should reject a spec that omits the required path", + ), + StageTestCase( + "near_origin_missing", + pipeline=[{"$search": {"near": {"path": "num", "pivot": 10}}}], + error_code=UNKNOWN_ERROR, + msg="$search near should reject a spec that omits the required origin", + ), + StageTestCase( + "near_pivot_missing", + pipeline=[{"$search": {"near": {"path": "num", "origin": 10}}}], + error_code=UNKNOWN_ERROR, + msg="$search near should reject a spec that omits the required pivot", + ), +] + +SEARCH_NEAR_ERROR_TESTS = ( + SEARCH_NEAR_PIVOT_ERROR_TESTS + + SEARCH_NEAR_ORIGIN_TYPE_ERROR_TESTS + + SEARCH_NEAR_REQUIRED_FIELD_ERROR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCH_NEAR_ERROR_TESTS)) +def test_search_near_errors(near_collection, test_case: StageTestCase): + """Test $search near rejects invalid pivot values and type-mismatched origins.""" + result = execute_command( + near_collection, + {"aggregate": near_collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult(result, error_code=test_case.error_code, msg=test_case.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_option_errors.py b/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_option_errors.py new file mode 100644 index 000000000..454332c7c --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_option_errors.py @@ -0,0 +1,270 @@ +"""Tests for $search cross-cutting stage option validation errors.""" + +from __future__ import annotations + +import datetime + +import pytest +from bson import Binary, Code, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + UNKNOWN_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_ONE_AND_HALF, + FLOAT_INFINITY, + FLOAT_NAN, + INT32_MAX, +) + +pytestmark = pytest.mark.requires(search=True) + + +# Property [Document-Typed Option Non-Document]: the count, highlight, and sort +# options must each be a document (a null value is treated as omitted). +SEARCH_DOCUMENT_OPTION_TYPE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"{opt}_type_{tid}", + pipeline=[ + {"$search": {"text": {"query": "quick", "path": "title"}, opt: val}}, + ], + error_code=UNKNOWN_ERROR, + msg=f"$search should reject a {tid} {opt} option as a non-document", + ) + for opt in ("count", "highlight", "sort") + for tid, val in [ + ("string", "x"), + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("bool", True), + ("array", [{}]), + ("objectid", ObjectId("0123456789abcdef01234567")), + ("datetime", datetime.datetime(2020, 1, 1)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ] +] + +# Property [phrase.slop Bound And Shared Integer Coercion]: phrase.slop has a +# non-negative bound (it accepts zero, unlike the positive-bound highlight integer +# sub-fields) and exercises the shared $search integer parser, which rejects a +# fractional, non-finite, or out-of-32-bit-range numeric value. +SEARCH_INTEGER_COERCION_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "slop_negative", + pipeline=[ + {"$search": {"phrase": {"query": "quick brown", "path": "title", "slop": -1}}}, + ], + error_code=UNKNOWN_ERROR, + msg="$search should reject a negative phrase.slop", + ), + StageTestCase( + "slop_fractional_double", + pipeline=[ + {"$search": {"phrase": {"query": "quick brown", "path": "title", "slop": 1.5}}}, + ], + error_code=UNKNOWN_ERROR, + msg="$search should reject a fractional-double phrase.slop as a non-integer", + ), + StageTestCase( + "slop_nan", + pipeline=[ + {"$search": {"phrase": {"query": "quick brown", "path": "title", "slop": FLOAT_NAN}}}, + ], + error_code=UNKNOWN_ERROR, + msg="$search should reject a NaN phrase.slop as a non-integer", + ), + StageTestCase( + "slop_positive_infinity", + pipeline=[ + { + "$search": { + "phrase": {"query": "quick brown", "path": "title", "slop": FLOAT_INFINITY} + } + }, + ], + error_code=UNKNOWN_ERROR, + msg="$search should reject an infinite phrase.slop as not fitting in a 32-bit integer", + ), + StageTestCase( + "slop_int64_over_int32", + pipeline=[ + { + "$search": { + "phrase": { + "query": "quick brown", + "path": "title", + "slop": Int64(INT32_MAX + 1), + } + } + }, + ], + error_code=UNKNOWN_ERROR, + msg="$search should reject an int64 phrase.slop past the 32-bit integer range", + ), +] + +# Property [Shared Integer Type Rejection]: the shared $search integer parser, +# exercised here through phrase.slop, accepts only whole numbers, so any +# non-numeric BSON type (plus Decimal128) is rejected. +SEARCH_INTEGER_TYPE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"slop_type_{tid}", + pipeline=[ + {"$search": {"phrase": {"query": "quick brown", "path": "title", "slop": val}}}, + ], + error_code=UNKNOWN_ERROR, + msg=f"$search should reject a {tid} phrase.slop as a non-integer", + ) + for tid, val in [ + ("string", "1"), + ("bool", True), + ("object", {"a": 1}), + ("array", [1]), + ("objectid", ObjectId("0123456789abcdef01234567")), + ("datetime", datetime.datetime(2020, 1, 1)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ] +] + +# Property [Unknown And Case-Variant Option]: an unknown top-level option field is +# rejected, and option names are matched exactly (case-sensitive and not +# whitespace-trimmed). +SEARCH_UNKNOWN_OPTION_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "option_unknown_field", + pipeline=[ + {"$search": {"text": {"query": "quick", "path": "title"}, "bogus": 1}}, + ], + error_code=UNKNOWN_ERROR, + msg="$search should reject an unknown top-level option field", + ), + *[ + StageTestCase( + f"option_name_{tid}", + pipeline=[ + {"$search": {"text": {"query": "quick", "path": "title"}, name: value}}, + ], + error_code=UNKNOWN_ERROR, + msg=f"$search should reject the {tid} option name as an unrecognized option", + ) + for tid, name, value in [ + ("capitalized_index", "Index", "default"), + ("trailing_space_index", "index ", "default"), + ] + ], +] + +# Property [searchNodePreference Validation]: searchNodePreference must be a +# document carrying a required string key, so a non-document, a missing key, and +# a non-string key are each rejected. +SEARCH_NODE_PREFERENCE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "search_node_preference_non_document", + pipeline=[ + { + "$search": { + "text": {"query": "quick", "path": "title"}, + "searchNodePreference": "primary", + } + }, + ], + error_code=UNKNOWN_ERROR, + msg="$search should reject a non-document searchNodePreference option", + ), + StageTestCase( + "search_node_preference_key_missing", + pipeline=[ + {"$search": {"text": {"query": "quick", "path": "title"}, "searchNodePreference": {}}}, + ], + error_code=UNKNOWN_ERROR, + msg="$search should reject a searchNodePreference that omits the required key", + ), + StageTestCase( + "search_node_preference_key_non_string", + pipeline=[ + { + "$search": { + "text": {"query": "quick", "path": "title"}, + "searchNodePreference": {"key": 1}, + } + }, + ], + error_code=UNKNOWN_ERROR, + msg="$search should reject a non-string searchNodePreference.key", + ), +] + +# Property [returnScope Validation]: returnScope must be a document carrying a +# required path, and a non-empty returnScope additionally requires +# returnStoredSource to be enabled. +SEARCH_RETURN_SCOPE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "return_scope_non_document", + pipeline=[ + {"$search": {"text": {"query": "quick", "path": "title"}, "returnScope": True}}, + ], + error_code=UNKNOWN_ERROR, + msg="$search should reject a non-document returnScope option", + ), + StageTestCase( + "return_scope_path_missing", + pipeline=[ + {"$search": {"text": {"query": "quick", "path": "title"}, "returnScope": {}}}, + ], + error_code=UNKNOWN_ERROR, + msg="$search should reject a returnScope that omits the required path", + ), + StageTestCase( + "return_scope_requires_stored_source", + pipeline=[ + { + "$search": { + "text": {"query": "quick", "path": "title"}, + "returnScope": {"path": "title"}, + } + }, + ], + error_code=UNKNOWN_ERROR, + msg="$search should reject a non-empty returnScope when returnStoredSource is not " + "enabled", + ), +] + +SEARCH_OPTION_GENERAL_ERROR_TESTS = ( + SEARCH_DOCUMENT_OPTION_TYPE_ERROR_TESTS + + SEARCH_INTEGER_COERCION_ERROR_TESTS + + SEARCH_INTEGER_TYPE_ERROR_TESTS + + SEARCH_UNKNOWN_OPTION_ERROR_TESTS + + SEARCH_NODE_PREFERENCE_ERROR_TESTS + + SEARCH_RETURN_SCOPE_ERROR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCH_OPTION_GENERAL_ERROR_TESTS)) +def test_search_option_errors(indexed_collection, test_case: StageTestCase): + """Test $search cross-cutting option validation: type, integer coercion, unknown options.""" + result = execute_command( + indexed_collection, + {"aggregate": indexed_collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult(result, error_code=test_case.error_code, msg=test_case.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_options.py b/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_options.py new file mode 100644 index 000000000..e957b519e --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_options.py @@ -0,0 +1,446 @@ +"""Tests for $search stage options (index, sort, tracking) and view resolution.""" + +from __future__ import annotations + +import datetime + +import pytest +from bson import Binary, Code, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.stages.search.utils.search_common import ( + SEARCH_INDEX_NAME, +) +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + UNKNOWN_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import ( + Contains, + Len, +) +from documentdb_tests.framework.test_constants import ( + DECIMAL128_ONE_AND_HALF, +) + +pytestmark = pytest.mark.requires(search=True) + + +# Property [Index Option]: the index option names the search index to query, so +# a name no index has returns nothing silently, and any string is accepted with +# no validation error. +SEARCH_INDEX_OPTION_TESTS: list[StageTestCase] = [ + StageTestCase( + "index_named_existing", + pipeline=[ + {"$search": {"text": {"query": "quick", "path": "title"}, "index": SEARCH_INDEX_NAME}}, + ], + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 1), + Contains("_id", 3), + Contains("_id", 4), + ] + }, + msg="$search should query the index named by a non-empty string index option", + ), + StageTestCase( + "index_nonexistent_silent", + pipeline=[ + {"$search": {"text": {"query": "quick", "path": "title"}, "index": "no_such_index"}}, + ], + expected={"cursor.firstBatch": Len(0)}, + msg="$search should return no documents and no error for a nonexistent index name", + ), + StageTestCase( + "index_name_1000_chars", + pipeline=[ + {"$search": {"text": {"query": "quick", "path": "title"}, "index": "a" * 1_000}}, + ], + expected={"cursor.firstBatch": Len(0)}, + msg="$search should accept a 1000-character index name with no length validation", + ), + StageTestCase( + "index_name_special_chars", + pipeline=[ + { + "$search": { + "text": {"query": "quick", "path": "title"}, + "index": "name with spaces!@#$%", + } + }, + ], + expected={"cursor.firstBatch": Len(0)}, + msg="$search should accept a special-character index name with no charset validation", + ), + StageTestCase( + "index_name_unicode", + pipeline=[ + { + "$search": { + "text": {"query": "quick", "path": "title"}, + "index": "\u00edndax\u00f1\u00e9\U0001f600", + } + }, + ], + expected={"cursor.firstBatch": Len(0)}, + msg="$search should accept a Unicode index name with no charset validation", + ), +] + +# Property [Sort Option]: a sort option document is accepted as a tiebreaker and +# the search still returns its matches. +SEARCH_SORT_OPTION_TESTS: list[StageTestCase] = [ + StageTestCase( + "sort_ascending", + pipeline=[ + {"$search": {"text": {"query": "quick", "path": "title"}, "sort": {"_id": 1}}}, + ], + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 1), + Contains("_id", 3), + Contains("_id", 4), + ] + }, + msg="$search should accept a single ascending sort key and still return its matches", + ), + StageTestCase( + "sort_descending", + pipeline=[ + {"$search": {"text": {"query": "quick", "path": "title"}, "sort": {"_id": -1}}}, + ], + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 1), + Contains("_id", 3), + Contains("_id", 4), + ] + }, + msg="$search should accept a single descending sort key and still return its matches", + ), + StageTestCase( + "sort_meta_search_score", + pipeline=[ + { + "$search": { + "text": {"query": "quick", "path": "title"}, + "sort": {"sc": {"$meta": "searchScore"}}, + } + }, + ], + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 1), + Contains("_id", 3), + Contains("_id", 4), + ] + }, + msg="$search should accept a $meta searchScore sort key and still return its matches", + ), + StageTestCase( + "sort_multi_key", + pipeline=[ + { + "$search": { + "text": {"query": "quick", "path": "title"}, + "sort": {"_id": 1, "title": 1}, + } + }, + ], + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 1), + Contains("_id", 3), + Contains("_id", 4), + ] + }, + msg="$search should accept a multi-key sort tiebreaker and still return its matches", + ), + StageTestCase( + "sort_meta_then_key", + pipeline=[ + { + "$search": { + "text": {"query": "quick", "path": "title"}, + "sort": {"sc": {"$meta": "searchScore"}, "_id": 1}, + } + }, + ], + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 1), + Contains("_id", 3), + Contains("_id", 4), + ] + }, + msg="$search should accept a $meta key with a multi-key tiebreaker and still return " + "its matches", + ), +] + +# Property [Tracking Option]: the tracking option is a recognized stage option +# and is accepted so the search still returns its matches. +SEARCH_TRACKING_OPTION_TESTS: list[StageTestCase] = [ + StageTestCase( + "tracking_recognized", + pipeline=[ + { + "$search": { + "text": {"query": "quick", "path": "title"}, + "tracking": {"searchTerms": "quick"}, + } + }, + ], + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 1), + Contains("_id", 3), + Contains("_id", 4), + ] + }, + msg="$search should accept the tracking option and still return its matches", + ), +] + +# Property [Concurrent Option]: the concurrent option is a recognized boolean stage +# option, so both true and false are accepted with no coercion and the search +# still returns its matches. +SEARCH_CONCURRENT_OPTION_TESTS: list[StageTestCase] = [ + StageTestCase( + f"concurrent_{label}", + pipeline=[ + {"$search": {"text": {"query": "quick", "path": "title"}, "concurrent": val}}, + ], + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 1), + Contains("_id", 3), + Contains("_id", 4), + ] + }, + msg=f"$search should accept a {label} concurrent option and still return its matches", + ) + for label, val in [("true", True), ("false", False)] +] + +# Property [searchNodePreference Option]: searchNodePreference is a recognized +# stage option taking a document with a string key, so a valid preference is +# accepted and the search still returns its matches. +SEARCH_NODE_PREFERENCE_OPTION_TESTS: list[StageTestCase] = [ + StageTestCase( + "search_node_preference_recognized", + pipeline=[ + { + "$search": { + "text": {"query": "quick", "path": "title"}, + "searchNodePreference": {"key": "primary"}, + } + }, + ], + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 1), + Contains("_id", 3), + Contains("_id", 4), + ] + }, + msg="$search should accept a searchNodePreference document and still return its matches", + ), +] + +SEARCH_OPTION_TESTS = ( + SEARCH_INDEX_OPTION_TESTS + + SEARCH_SORT_OPTION_TESTS + + SEARCH_TRACKING_OPTION_TESTS + + SEARCH_CONCURRENT_OPTION_TESTS + + SEARCH_NODE_PREFERENCE_OPTION_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCH_OPTION_TESTS)) +def test_search_options_cases(indexed_collection, test_case: StageTestCase): + """Test $search index, sort, and tracking stage options.""" + result = execute_command( + indexed_collection, + {"aggregate": indexed_collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult( + result, + expected=test_case.expected, + msg=test_case.msg, + raw_res=True, + ) + + +# Property [View Index Resolution]: a $search against a view resolves the +# underlying collection's search index and returns the underlying collection's +# matching documents. +@pytest.mark.aggregate +def test_search_view_resolves_underlying_index(indexed_collection): + """Test $search over a view resolves the underlying collection's search index.""" + db = indexed_collection.database + view_name = "view_over_indexed" + db.command({"create": view_name, "viewOn": indexed_collection.name, "pipeline": []}) + try: + result = execute_command( + db[view_name], + { + "aggregate": db[view_name].name, + "pipeline": [{"$search": {"text": {"query": "quick", "path": "title"}}}], + "cursor": {}, + }, + ) + finally: + db.drop_collection(view_name) + assertResult( + result, + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 1), + Contains("_id", 3), + Contains("_id", 4), + ] + }, + msg="$search over a view should resolve the underlying collection's index for its matches", + raw_res=True, + ) + + +# Property [Index Option Type And Value]: the index option must be a non-empty +# string (a null index is treated as the default). +SEARCH_INDEX_OPTION_ERROR_TESTS: list[StageTestCase] = [ + *[ + StageTestCase( + f"index_type_{tid}", + pipeline=[ + {"$search": {"text": {"query": "quick", "path": "title"}, "index": val}}, + ], + error_code=UNKNOWN_ERROR, + msg=f"$search should reject a {tid} index option as a non-string", + ) + for tid, val in [ + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("bool", True), + ("object", {"name": "default"}), + ("array", ["default"]), + ("objectid", ObjectId("0123456789abcdef01234567")), + ("datetime", datetime.datetime(2020, 1, 1)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ] + ], + StageTestCase( + "index_empty_string", + pipeline=[ + {"$search": {"text": {"query": "quick", "path": "title"}, "index": ""}}, + ], + error_code=UNKNOWN_ERROR, + msg="$search should reject an empty-string index option", + ), +] + +# Property [Sort Value Validation]: sort requires at least one field, rejects a +# direction other than 1 or -1, and rejects a $meta sort key other than +# searchScore. +SEARCH_SORT_VALUE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "sort_empty", + pipeline=[ + {"$search": {"text": {"query": "quick", "path": "title"}, "sort": {}}}, + ], + error_code=UNKNOWN_ERROR, + msg="$search should reject a sort document with no sort field", + ), + StageTestCase( + "sort_bad_direction", + pipeline=[ + {"$search": {"text": {"query": "quick", "path": "title"}, "sort": {"n": 2}}}, + ], + error_code=UNKNOWN_ERROR, + msg="$search should reject a sort direction other than 1 or -1", + ), + StageTestCase( + "sort_bad_meta_key", + pipeline=[ + { + "$search": { + "text": {"query": "quick", "path": "title"}, + "sort": {"n": {"$meta": "bogus"}}, + } + }, + ], + error_code=UNKNOWN_ERROR, + msg="$search should reject a $meta sort key other than searchScore", + ), +] + +# Property [Concurrent Option Type]: the concurrent option must be a boolean, so a +# value of any non-boolean BSON type is rejected with no coercion. A null concurrent +# is treated as the default (a success), so it is excluded. +SEARCH_CONCURRENT_OPTION_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"concurrent_type_{tid}", + pipeline=[ + {"$search": {"text": {"query": "quick", "path": "title"}, "concurrent": val}}, + ], + error_code=UNKNOWN_ERROR, + msg=f"$search should reject a {tid} concurrent option as a non-boolean", + ) + for tid, val in [ + ("string", "true"), + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("object", {"a": 1}), + ("array", [True]), + ("objectid", ObjectId("0123456789abcdef01234567")), + ("datetime", datetime.datetime(2020, 1, 1)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ] +] + +SEARCH_OPTION_ERROR_TESTS = ( + SEARCH_INDEX_OPTION_ERROR_TESTS + + SEARCH_SORT_VALUE_ERROR_TESTS + + SEARCH_CONCURRENT_OPTION_ERROR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCH_OPTION_ERROR_TESTS)) +def test_search_options_errors(indexed_collection, test_case: StageTestCase): + """Test $search index and sort option validation errors.""" + result = execute_command( + indexed_collection, + {"aggregate": indexed_collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult(result, error_code=test_case.error_code, msg=test_case.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_pagination.py b/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_pagination.py new file mode 100644 index 000000000..33254eea4 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_pagination.py @@ -0,0 +1,184 @@ +"""Tests for the $search searchAfter and searchBefore pagination options.""" + +from __future__ import annotations + +import datetime + +import pytest +from bson import Binary, Code, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + UNKNOWN_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import ( + Eq, + Len, +) +from documentdb_tests.framework.test_constants import ( + DECIMAL128_ONE_AND_HALF, +) + +pytestmark = pytest.mark.requires(search=True) + + +def _search_ids(collection, spec: dict) -> list: + """Run a $search spec and return the matched _id values in result order.""" + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [{"$search": spec}, {"$project": {"_id": 1}}], + "cursor": {}, + }, + ) + return [doc["_id"] for doc in result["cursor"]["firstBatch"]] + + +def _sequence_token(collection, spec: dict, position: int) -> str: + """Capture the searchSequenceToken of the result at the given position.""" + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + {"$search": spec}, + {"$project": {"tok": {"$meta": "searchSequenceToken"}}}, + ], + "cursor": {}, + }, + ) + return str(result["cursor"]["firstBatch"][position]["tok"]) + + +def _expected_id_order(ids: list) -> dict: + """Build an assertResult expected dict asserting firstBatch holds exactly these + _id values in this order.""" + expected: dict = {"cursor.firstBatch": Len(len(ids))} + for i, _id in enumerate(ids): + expected[f"cursor.firstBatch.{i}._id"] = Eq(_id) + return expected + + +# Property [searchAfter Pagination]: searchAfter resumes the result stream +# immediately after the result whose searchSequenceToken is supplied, so paging +# from the first result's token yields exactly the remaining results in order. +@pytest.mark.aggregate +def test_search_after_pages_to_following_results(indexed_collection): + """Test $search searchAfter resumes immediately after a captured sequence token.""" + spec = {"text": {"query": "quick", "path": "title"}} + full_ids = _search_ids(indexed_collection, spec) + first_token = _sequence_token(indexed_collection, spec, 0) + result = execute_command( + indexed_collection, + { + "aggregate": indexed_collection.name, + "pipeline": [ + {"$search": {**spec, "searchAfter": first_token}}, + {"$project": {"_id": 1}}, + ], + "cursor": {}, + }, + ) + assertResult( + result, + expected=_expected_id_order(full_ids[1:]), + msg="$search searchAfter should resume immediately after the first result's token", + raw_res=True, + ) + + +# Property [searchBefore Pagination]: searchBefore returns the results preceding +# the result whose searchSequenceToken is supplied, in reverse result order, so +# paging from the last result's token yields the earlier results reversed. +@pytest.mark.aggregate +def test_search_before_pages_to_preceding_results(indexed_collection): + """Test $search searchBefore returns the results preceding a captured sequence token.""" + spec = {"text": {"query": "quick", "path": "title"}} + full_ids = _search_ids(indexed_collection, spec) + last_token = _sequence_token(indexed_collection, spec, len(full_ids) - 1) + result = execute_command( + indexed_collection, + { + "aggregate": indexed_collection.name, + "pipeline": [ + {"$search": {**spec, "searchBefore": last_token}}, + {"$project": {"_id": 1}}, + ], + "cursor": {}, + }, + ) + assertResult( + result, + expected=_expected_id_order(list(reversed(full_ids[:-1]))), + msg="$search searchBefore should return the results preceding the last result's token " + "in reverse order", + raw_res=True, + ) + + +# Property [Pagination Token Type]: searchAfter and searchBefore are string-only +# pagination tokens, so a value of any non-string BSON type is rejected with no +# coercion. A null token is treated as omitted (a default), so it is excluded. +SEARCH_PAGINATION_TOKEN_TYPE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"{opt}_type_{tid}", + pipeline=[ + {"$search": {"text": {"query": "quick", "path": "title"}, opt: val}}, + ], + error_code=UNKNOWN_ERROR, + msg=f"$search should reject a {tid} {opt} token as a non-string", + ) + for opt in ("searchAfter", "searchBefore") + for tid, val in [ + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("bool", True), + ("object", {"a": 1}), + ("array", ["abc"]), + ("objectid", ObjectId("0123456789abcdef01234567")), + ("datetime", datetime.datetime(2020, 1, 1)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ] +] + +# Property [Pagination Token Format]: a non-empty string that is not a valid +# encoded sequence token is rejected as a malformed token value. +SEARCH_PAGINATION_TOKEN_FORMAT_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"{opt}_bad_format", + pipeline=[ + {"$search": {"text": {"query": "quick", "path": "title"}, opt: "not_a_token"}}, + ], + error_code=UNKNOWN_ERROR, + msg=f"$search should reject a malformed {opt} string as an invalid token value", + ) + for opt in ("searchAfter", "searchBefore") +] + +SEARCH_PAGINATION_ERROR_TESTS = ( + SEARCH_PAGINATION_TOKEN_TYPE_ERROR_TESTS + SEARCH_PAGINATION_TOKEN_FORMAT_ERROR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCH_PAGINATION_ERROR_TESTS)) +def test_search_pagination_errors(indexed_collection, test_case: StageTestCase): + """Test $search searchAfter and searchBefore reject non-string and malformed tokens.""" + result = execute_command( + indexed_collection, + {"aggregate": indexed_collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult(result, error_code=test_case.error_code, msg=test_case.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_phrase.py b/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_phrase.py new file mode 100644 index 000000000..162d2402a --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_phrase.py @@ -0,0 +1,224 @@ +"""Tests for the $search phrase operator.""" + +from __future__ import annotations + +import datetime + +import pytest +from bson import Binary, Code, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + UNKNOWN_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import ( + Contains, + Len, +) +from documentdb_tests.framework.test_constants import ( + DECIMAL128_ONE_AND_HALF, +) + +pytestmark = pytest.mark.requires(search=True) + + +# Property [phrase slop Proximity]: phrase.slop bounds how far apart the query +# terms may sit and still match: slop 0 requires strict adjacency and a positive +# integer permits that many intervening positions. +SEARCH_PHRASE_SLOP_TESTS: list[StageTestCase] = [ + StageTestCase( + "phrase_slop_0_adjacent", + pipeline=[ + {"$search": {"phrase": {"query": "quick brown", "path": "title", "slop": 0}}}, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 1)]}, + msg="$search phrase with slop 0 should match adjacent query terms", + ), + StageTestCase( + "phrase_score_boost", + pipeline=[ + { + "$search": { + "phrase": { + "query": "quick brown", + "path": "title", + "slop": 0, + "score": {"boost": {"value": 2.0}}, + } + } + }, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 1)]}, + msg="$search phrase should accept a score modifier and still return its matches", + ), + StageTestCase( + "phrase_slop_0_excludes_gap", + pipeline=[ + {"$search": {"phrase": {"query": "quick fox", "path": "title", "slop": 0}}}, + ], + expected={"cursor.firstBatch": Len(0)}, + msg="$search phrase with slop 0 should require strict adjacency and exclude a " + "document with an intervening token", + ), + StageTestCase( + "phrase_slop_positive_int", + pipeline=[ + {"$search": {"phrase": {"query": "quick fox", "path": "title", "slop": 1}}}, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 1)]}, + msg="$search phrase with a positive integer slop should permit the intervening token", + ), + StageTestCase( + "phrase_slop_whole_double", + pipeline=[ + {"$search": {"phrase": {"query": "quick fox", "path": "title", "slop": 2.0}}}, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 1)]}, + msg="$search phrase should accept a whole-number double slop as the proximity bound", + ), + StageTestCase( + "phrase_slop_large", + pipeline=[ + {"$search": {"phrase": {"query": "quick fox", "path": "title", "slop": 1_000_000}}}, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 1)]}, + msg="$search phrase should accept a very large slop as the proximity bound", + ), +] + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCH_PHRASE_SLOP_TESTS)) +def test_search_phrase_slop_cases(indexed_collection, test_case: StageTestCase): + """Test $search phrase slop proximity matching.""" + result = execute_command( + indexed_collection, + {"aggregate": indexed_collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult( + result, + expected=test_case.expected, + msg=test_case.msg, + raw_res=True, + ) + + +# Property [phrase query Validation]: phrase.query is required and must be a +# string or array of non-null strings. +SEARCH_PHRASE_QUERY_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "phrase_query_missing", + pipeline=[{"$search": {"phrase": {"path": "title"}}}], + error_code=UNKNOWN_ERROR, + msg="$search phrase should reject an operator missing the required query", + ), + *[ + StageTestCase( + f"phrase_query_non_string_{tid}", + pipeline=[{"$search": {"phrase": {"query": val, "path": "title"}}}], + error_code=UNKNOWN_ERROR, + msg=f"$search phrase should reject a {tid} query as a non-string", + ) + for tid, val in [ + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("bool", True), + ("object", {"q": "quick"}), + ("objectid", ObjectId("0123456789abcdef01234567")), + ("datetime", datetime.datetime(2020, 1, 1)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ] + ], + StageTestCase( + "phrase_query_array_element_null", + pipeline=[ + {"$search": {"phrase": {"query": ["quick brown", None], "path": "title"}}}, + ], + error_code=UNKNOWN_ERROR, + msg="$search phrase should reject a null query-array element", + ), + StageTestCase( + "phrase_query_array_element_non_string", + pipeline=[ + {"$search": {"phrase": {"query": ["quick brown", 1], "path": "title"}}}, + ], + error_code=UNKNOWN_ERROR, + msg="$search phrase should reject a non-string query-array element", + ), +] + +# Property [phrase path Validation]: phrase.path is required and must be a string, +# document, or array of paths. +SEARCH_PHRASE_PATH_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "phrase_path_missing", + pipeline=[{"$search": {"phrase": {"query": "quick brown"}}}], + error_code=UNKNOWN_ERROR, + msg="$search phrase should reject an operator missing the required path", + ), + *[ + StageTestCase( + f"phrase_path_{tid}", + pipeline=[{"$search": {"phrase": {"query": "quick brown", "path": val}}}], + error_code=UNKNOWN_ERROR, + msg=f"$search phrase should reject a {tid} path as neither a string, document, " + "nor array", + ) + for tid, val in [ + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("bool", True), + ("objectid", ObjectId("0123456789abcdef01234567")), + ("datetime", datetime.datetime(2020, 1, 1)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ] + ], +] + +# Property [phrase fuzzy Rejection]: phrase does not accept a fuzzy sub-field. +SEARCH_PHRASE_FUZZY_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "phrase_fuzzy_unrecognized", + pipeline=[ + {"$search": {"phrase": {"query": "quick brown", "path": "title", "fuzzy": {}}}}, + ], + error_code=UNKNOWN_ERROR, + msg="$search should reject a phrase.fuzzy sub-field as unrecognized", + ), +] + +SEARCH_PHRASE_ERROR_TESTS = ( + SEARCH_PHRASE_QUERY_ERROR_TESTS + + SEARCH_PHRASE_PATH_ERROR_TESTS + + SEARCH_PHRASE_FUZZY_ERROR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCH_PHRASE_ERROR_TESTS)) +def test_search_phrase_errors(indexed_collection, test_case: StageTestCase): + """Test $search phrase rejects bad query/path values and an unsupported fuzzy sub-field.""" + result = execute_command( + indexed_collection, + {"aggregate": indexed_collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult(result, error_code=test_case.error_code, msg=test_case.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_queryString.py b/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_queryString.py new file mode 100644 index 000000000..fbc836e9b --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_queryString.py @@ -0,0 +1,124 @@ +"""Tests for the $search queryString operator.""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import UNKNOWN_ERROR +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Contains, Len + +pytestmark = pytest.mark.requires(search=True) + + +# Property [queryString Default Path]: a bare query term is matched against the +# defaultPath, returning every document whose defaultPath contains the term. +SEARCH_QUERYSTRING_DEFAULT_PATH_TESTS: list[StageTestCase] = [ + StageTestCase( + "querystring_default_path_term", + pipeline=[{"$search": {"queryString": {"defaultPath": "title", "query": "quick"}}}], + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 1), + Contains("_id", 3), + Contains("_id", 4), + ] + }, + msg="$search queryString should match a bare term against the defaultPath", + ), +] + +# Property [queryString Boolean Operators]: the Lucene AND and OR operators +# intersect and union the per-term matches respectively. +SEARCH_QUERYSTRING_BOOLEAN_TESTS: list[StageTestCase] = [ + StageTestCase( + "querystring_boolean_and", + pipeline=[ + {"$search": {"queryString": {"defaultPath": "title", "query": "quick AND brown"}}} + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 1)]}, + msg="$search queryString AND should match only documents containing both terms", + ), + StageTestCase( + "querystring_boolean_or", + pipeline=[ + {"$search": {"queryString": {"defaultPath": "title", "query": "quick OR turtle"}}} + ], + expected={ + "cursor.firstBatch": [ + Len(4), + Contains("_id", 1), + Contains("_id", 2), + Contains("_id", 3), + Contains("_id", 4), + ] + }, + msg="$search queryString OR should match documents containing either term", + ), +] + +# Property [queryString Field Scoping]: a field:term clause overrides the +# defaultPath and matches against the named field instead. +SEARCH_QUERYSTRING_FIELD_TESTS: list[StageTestCase] = [ + StageTestCase( + "querystring_field_scoped", + pipeline=[{"$search": {"queryString": {"defaultPath": "title", "query": "body:quick"}}}], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 2)]}, + msg="$search queryString should scope a field:term clause to the named field, not the " + "defaultPath", + ), +] + +SEARCH_QUERYSTRING_SUCCESS_TESTS = ( + SEARCH_QUERYSTRING_DEFAULT_PATH_TESTS + + SEARCH_QUERYSTRING_BOOLEAN_TESTS + + SEARCH_QUERYSTRING_FIELD_TESTS +) + +# Property [queryString Validation]: queryString requires both defaultPath and +# query, and an unparseable query string is rejected. +SEARCH_QUERYSTRING_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "querystring_missing_query", + pipeline=[{"$search": {"queryString": {"defaultPath": "title"}}}], + error_code=UNKNOWN_ERROR, + msg="$search queryString should reject a spec missing the required query", + ), + StageTestCase( + "querystring_missing_default_path", + pipeline=[{"$search": {"queryString": {"query": "quick"}}}], + error_code=UNKNOWN_ERROR, + msg="$search queryString should reject a spec missing the required defaultPath", + ), + StageTestCase( + "querystring_unparseable_query", + pipeline=[{"$search": {"queryString": {"defaultPath": "title", "query": "quick AND AND"}}}], + error_code=UNKNOWN_ERROR, + msg="$search queryString should reject an unparseable Lucene query string", + ), +] + +SEARCH_QUERYSTRING_TESTS = SEARCH_QUERYSTRING_SUCCESS_TESTS + SEARCH_QUERYSTRING_ERROR_TESTS + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCH_QUERYSTRING_TESTS)) +def test_search_queryString_cases(indexed_collection, test_case: StageTestCase): + """Test $search queryString default-path, boolean, field-scoped, and error cases.""" + result = execute_command( + indexed_collection, + {"aggregate": indexed_collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_range.py b/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_range.py new file mode 100644 index 000000000..097c319eb --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_range.py @@ -0,0 +1,683 @@ +"""Tests for the $search range operator.""" + +from __future__ import annotations + +import datetime + +import pytest +from bson import Binary, Code, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.stages.search.utils.search_common import ( + create_search_index, +) +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, +) +from documentdb_tests.framework import fixtures +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + UNKNOWN_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import ( + Contains, + Len, +) +from documentdb_tests.framework.test_constants import ( + DECIMAL128_ONE_AND_HALF, + DOUBLE_MAX, + DOUBLE_MIN_SUBNORMAL, + DOUBLE_NEGATIVE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + INT32_MIN, +) + +pytestmark = pytest.mark.requires(search=True) + + +_RANGE_DOCS = [ + {"_id": 1, "num": -5}, + {"_id": 2, "num": 0}, + {"_id": 3, "num": 5}, + {"_id": 4, "num": 10}, + {"_id": 5, "num": 20}, + {"_id": 6, "dt": datetime.datetime(1960, 1, 1)}, # pre-epoch + {"_id": 7, "dt": datetime.datetime(1970, 1, 1)}, # epoch + {"_id": 8, "dt": datetime.datetime(2020, 1, 1, 0, 0, 0, 123000)}, # sub-second + {"_id": 9, "dt": datetime.datetime(9999, 12, 31)}, # far future + {"_id": 10, "tok": "mango"}, # lowercase token + {"_id": 11, "tok": "Mango"}, # capitalized token, sorts before lowercase + {"_id": 12, "tok": ""}, # empty-string token, sorts first + {"_id": 13, "tok": "papaya"}, # token sorting after mango + {"_id": 14, "oid": ObjectId("000000000000000000000001")}, + {"_id": 15, "oid": ObjectId("000000000000000000000002")}, + {"_id": 16, "oid": ObjectId("000000000000000000000003")}, +] + +_RANGE_INDEX_DEFINITION = { + "mappings": { + "dynamic": False, + "fields": { + "num": {"type": "number"}, + "dt": {"type": "date"}, + "tok": {"type": "token"}, + "oid": {"type": "objectId"}, + }, + } +} + + +@pytest.fixture(scope="module") +def range_collection(engine_client, worker_id): + """A module-scoped collection with a static search index mapping a numeric, a + date, and a token-typed field, shared read-only across the range cases so the + index is built and polled once.""" + db_name = fixtures.generate_database_name("stages_search_range", worker_id) + fixtures.cleanup_database(engine_client, db_name) + db = engine_client[db_name] + coll = db["range_op"] + coll.insert_many(_RANGE_DOCS) + create_search_index(coll, _RANGE_INDEX_DEFINITION) + yield coll + fixtures.cleanup_database(engine_client, db_name) + + +# Property [Range Numeric Bound Type And Value Acceptance]: range accepts int32, +# int64, and double numeric bounds (matching identically for the same value) +# across the full numeric range with no error. +SEARCH_RANGE_NUMERIC_BOUND_TESTS: list[StageTestCase] = [ + StageTestCase( + "numeric_bound_int32", + pipeline=[{"$search": {"range": {"path": "num", "lte": 5}}}], + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 1), + Contains("_id", 2), + Contains("_id", 3), + ] + }, + msg="$search range should apply an int32 numeric bound", + ), + StageTestCase( + "range_score_boost", + pipeline=[ + {"$search": {"range": {"path": "num", "lte": 5, "score": {"boost": {"value": 2.0}}}}}, + ], + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 1), + Contains("_id", 2), + Contains("_id", 3), + ] + }, + msg="$search range should accept a score modifier and still return its matches", + ), + StageTestCase( + "numeric_bound_int64", + pipeline=[ + {"$search": {"range": {"path": "num", "lte": Int64(5)}}}, + ], + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 1), + Contains("_id", 2), + Contains("_id", 3), + ] + }, + msg="$search range should apply an int64 numeric bound identically to int32", + ), + StageTestCase( + "numeric_bound_double", + pipeline=[{"$search": {"range": {"path": "num", "lte": 5.0}}}], + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 1), + Contains("_id", 2), + Contains("_id", 3), + ] + }, + msg="$search range should apply a double numeric bound identically to int32", + ), + StageTestCase( + "numeric_bound_negative", + pipeline=[{"$search": {"range": {"path": "num", "lte": -5}}}], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 1)]}, + msg="$search range should apply a negative numeric bound", + ), + StageTestCase( + "numeric_bound_zero", + pipeline=[{"$search": {"range": {"path": "num", "lt": 0}}}], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 1)]}, + msg="$search range should apply a zero numeric bound", + ), + StageTestCase( + "numeric_bound_negative_zero", + pipeline=[ + {"$search": {"range": {"path": "num", "lt": DOUBLE_NEGATIVE_ZERO}}}, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 1)]}, + msg="$search range should treat a -0.0 bound identically to a 0 bound", + ), + StageTestCase( + "numeric_bound_subnormal", + # The smallest positive subnormal double is a tiny positive bound, so it + # excludes the 0 and -5 docs that a 0 bound would otherwise admit. + pipeline=[ + {"$search": {"range": {"path": "num", "gte": DOUBLE_MIN_SUBNORMAL}}}, + ], + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 3), + Contains("_id", 4), + Contains("_id", 5), + ] + }, + msg="$search range should apply the smallest subnormal double as a tiny positive bound", + ), + StageTestCase( + "numeric_bound_int32_min", + pipeline=[ + {"$search": {"range": {"path": "num", "gte": INT32_MIN}}}, + ], + expected={ + "cursor.firstBatch": [ + Len(5), + Contains("_id", 1), + Contains("_id", 2), + Contains("_id", 3), + Contains("_id", 4), + Contains("_id", 5), + ] + }, + msg="$search range should apply an int32-min bound, admitting every greater document", + ), + StageTestCase( + "numeric_bound_double_max", + pipeline=[ + {"$search": {"range": {"path": "num", "gt": DOUBLE_MAX}}}, + ], + expected={"cursor.firstBatch": Len(0)}, + msg="$search range should apply a DBL_MAX bound, matching nothing in a finite sample", + ), +] + +# Property [Range Datetime Bound Full Precision]: a datetime bound on a date path +# honors full millisecond precision. +SEARCH_RANGE_DATE_BOUND_TESTS: list[StageTestCase] = [ + StageTestCase( + "date_bound_epoch", + pipeline=[ + {"$search": {"range": {"path": "dt", "gte": datetime.datetime(1970, 1, 1)}}}, + ], + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 7), + Contains("_id", 8), + Contains("_id", 9), + ] + }, + msg="$search range should apply an epoch bound, excluding the pre-epoch document", + ), + StageTestCase( + "date_bound_pre_epoch", + pipeline=[ + {"$search": {"range": {"path": "dt", "lt": datetime.datetime(1970, 1, 1)}}}, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 6)]}, + msg="$search range should select the pre-epoch document below an epoch bound", + ), + StageTestCase( + "date_bound_far_future", + pipeline=[ + {"$search": {"range": {"path": "dt", "gte": datetime.datetime(9999, 12, 31)}}}, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 9)]}, + msg="$search range should apply a far-future year-9999 bound", + ), + StageTestCase( + "date_bound_millisecond_exact", + pipeline=[ + { + "$search": { + "range": { + "path": "dt", + "gte": datetime.datetime(2020, 1, 1, 0, 0, 0, 123000), + } + } + }, + ], + expected={"cursor.firstBatch": [Len(2), Contains("_id", 8), Contains("_id", 9)]}, + msg="$search range should select the millisecond-precision document at its exact bound", + ), + StageTestCase( + "date_bound_millisecond_after", + # One millisecond past the stored sub-second time excludes that document, + # so the bound's millisecond component is honored rather than truncated. + pipeline=[ + { + "$search": { + "range": { + "path": "dt", + "gte": datetime.datetime(2020, 1, 1, 0, 0, 0, 124000), + } + } + }, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 9)]}, + msg="$search range should honor the millisecond component of a datetime bound", + ), +] + +# Property [Range Inclusive And Exclusive Bounds]: gte and lte include the +# boundary value while gt and lt exclude it, and an inclusive degenerate interval +# (gte equal to lte) matches the boundary value. +SEARCH_RANGE_INCLUSIVE_EXCLUSIVE_TESTS: list[StageTestCase] = [ + StageTestCase( + "inclusive_gte", + pipeline=[{"$search": {"range": {"path": "num", "gte": 10}}}], + expected={"cursor.firstBatch": [Len(2), Contains("_id", 4), Contains("_id", 5)]}, + msg="$search range gte should include the boundary value", + ), + StageTestCase( + "exclusive_gt", + pipeline=[{"$search": {"range": {"path": "num", "gt": 10}}}], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 5)]}, + msg="$search range gt should exclude the boundary value", + ), + StageTestCase( + "inclusive_lte", + pipeline=[{"$search": {"range": {"path": "num", "lte": 10}}}], + expected={ + "cursor.firstBatch": [ + Len(4), + Contains("_id", 1), + Contains("_id", 2), + Contains("_id", 3), + Contains("_id", 4), + ] + }, + msg="$search range lte should include the boundary value", + ), + StageTestCase( + "exclusive_lt", + pipeline=[{"$search": {"range": {"path": "num", "lt": 10}}}], + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 1), + Contains("_id", 2), + Contains("_id", 3), + ] + }, + msg="$search range lt should exclude the boundary value", + ), + StageTestCase( + "degenerate_inclusive_interval", + pipeline=[ + {"$search": {"range": {"path": "num", "gte": 10, "lte": 10}}}, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 4)]}, + msg="$search range should match the boundary value for an inclusive degenerate interval", + ), +] + +# Property [Range Non-Finite Bounds]: a +inf bound matches no documents, a -inf +# bound matches every document, and a NaN bound matches no documents, all with no +# error. +SEARCH_RANGE_NON_FINITE_TESTS: list[StageTestCase] = [ + StageTestCase( + "non_finite_positive_infinity", + pipeline=[ + {"$search": {"range": {"path": "num", "gte": FLOAT_INFINITY}}}, + ], + expected={"cursor.firstBatch": Len(0)}, + msg="$search range should match no documents for a +inf bound", + ), + StageTestCase( + "non_finite_negative_infinity", + pipeline=[ + {"$search": {"range": {"path": "num", "gte": FLOAT_NEGATIVE_INFINITY}}}, + ], + expected={ + "cursor.firstBatch": [ + Len(5), + Contains("_id", 1), + Contains("_id", 2), + Contains("_id", 3), + Contains("_id", 4), + Contains("_id", 5), + ] + }, + msg="$search range should match every document for a -inf bound", + ), + StageTestCase( + "non_finite_nan", + pipeline=[ + {"$search": {"range": {"path": "num", "gte": FLOAT_NAN}}}, + ], + expected={"cursor.firstBatch": Len(0)}, + msg="$search range should match no documents for a NaN bound", + ), +] + +# Property [Range Type-Mismatched Bound Silent No-Match]: a string bound on a +# numeric path and a numeric bound on a token/string path each return no documents +# with no error. +SEARCH_RANGE_TYPE_MISMATCH_TESTS: list[StageTestCase] = [ + StageTestCase( + "mismatch_string_bound_numeric_path", + pipeline=[{"$search": {"range": {"path": "num", "gte": "5"}}}], + expected={"cursor.firstBatch": Len(0)}, + msg="$search range should return no documents and no error for a string bound on a " + "numeric path", + ), + StageTestCase( + "mismatch_numeric_bound_token_path", + pipeline=[{"$search": {"range": {"path": "tok", "gte": 5}}}], + expected={"cursor.firstBatch": Len(0)}, + msg="$search range should return no documents and no error for a numeric bound on a " + "token path", + ), +] + +# Property [Range Lexicographic Case-Sensitive Order]: over a token-mapped path, +# string range bounds compare in raw code-point order with no case folding. +SEARCH_RANGE_LEXICOGRAPHIC_ORDER_TESTS: list[StageTestCase] = [ + StageTestCase( + "lexicographic_capital_in_upper_range", + pipeline=[ + {"$search": {"range": {"path": "tok", "gte": "A", "lte": "z"}}}, + ], + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 10), + Contains("_id", 11), + Contains("_id", 13), + ] + }, + msg="$search range should include a capitalized token within an A-to-z code-point range", + ), + StageTestCase( + "lexicographic_capital_excluded_below_lowercase", + pipeline=[ + {"$search": {"range": {"path": "tok", "gte": "m", "lte": "z"}}}, + ], + expected={"cursor.firstBatch": [Len(2), Contains("_id", 10), Contains("_id", 13)]}, + msg="$search range should exclude a capitalized token below a lowercase lower bound, " + "confirming case sensitivity", + ), +] + +# Property [Range String Inclusive And Exclusive Bounds]: gte and lte include a +# string bound while gt and lt exclude it, and an inclusive degenerate interval +# matches the boundary token. +SEARCH_RANGE_STRING_INCLUSIVE_EXCLUSIVE_TESTS: list[StageTestCase] = [ + StageTestCase( + "string_gte_lte_include_bounds", + pipeline=[ + {"$search": {"range": {"path": "tok", "gte": "mango", "lte": "papaya"}}}, + ], + expected={"cursor.firstBatch": [Len(2), Contains("_id", 10), Contains("_id", 13)]}, + msg="$search range gte and lte should include both string boundary tokens", + ), + StageTestCase( + "string_gt_excludes_lower", + pipeline=[ + {"$search": {"range": {"path": "tok", "gt": "mango", "lte": "papaya"}}}, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 13)]}, + msg="$search range gt should exclude the lower string boundary token", + ), + StageTestCase( + "string_lt_excludes_upper", + pipeline=[ + {"$search": {"range": {"path": "tok", "gte": "mango", "lt": "papaya"}}}, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 10)]}, + msg="$search range lt should exclude the upper string boundary token", + ), + StageTestCase( + "string_degenerate_inclusive", + pipeline=[ + {"$search": {"range": {"path": "tok", "gte": "mango", "lte": "mango"}}}, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 10)]}, + msg="$search range should match the boundary token for an inclusive degenerate string " + "interval", + ), +] + +# Property [Range Empty-String Bounds]: an empty-string lower bound matches every +# stored token while an empty-string upper bound matches only the stored +# empty-string token. +SEARCH_RANGE_EMPTY_STRING_BOUND_TESTS: list[StageTestCase] = [ + StageTestCase( + "empty_string_gte_matches_all", + pipeline=[{"$search": {"range": {"path": "tok", "gte": ""}}}], + expected={ + "cursor.firstBatch": [ + Len(4), + Contains("_id", 10), + Contains("_id", 11), + Contains("_id", 12), + Contains("_id", 13), + ] + }, + msg="$search range should match every stored token for an empty-string lower bound", + ), + StageTestCase( + "empty_string_lte_matches_empty_only", + pipeline=[{"$search": {"range": {"path": "tok", "lte": ""}}}], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 12)]}, + msg="$search range should match only the empty-string token for an empty-string upper " + "bound", + ), +] + +# Property [Range ObjectId Bounds]: ObjectId is a supported bound type, so over an +# objectId-mapped path the bounds order by ObjectId value, with gte/lte inclusive +# and gt/lt exclusive. +SEARCH_RANGE_OBJECTID_BOUND_TESTS: list[StageTestCase] = [ + StageTestCase( + "objectid_gte", + pipeline=[ + {"$search": {"range": {"path": "oid", "gte": ObjectId("000000000000000000000002")}}}, + ], + expected={"cursor.firstBatch": [Len(2), Contains("_id", 15), Contains("_id", 16)]}, + msg="$search range gte should include the boundary ObjectId and every greater one", + ), + StageTestCase( + "objectid_gt", + pipeline=[ + {"$search": {"range": {"path": "oid", "gt": ObjectId("000000000000000000000002")}}}, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 16)]}, + msg="$search range gt should exclude the boundary ObjectId", + ), + StageTestCase( + "objectid_lte", + pipeline=[ + {"$search": {"range": {"path": "oid", "lte": ObjectId("000000000000000000000002")}}}, + ], + expected={"cursor.firstBatch": [Len(2), Contains("_id", 14), Contains("_id", 15)]}, + msg="$search range lte should include the boundary ObjectId and every lesser one", + ), +] + +# Property [Range doesNotAffect Option]: range recognizes a string doesNotAffect +# option (unlike text or near, which reject the field), accepting it and still +# returning its matches. +SEARCH_RANGE_DOES_NOT_AFFECT_TESTS: list[StageTestCase] = [ + StageTestCase( + "range_does_not_affect_string", + pipeline=[{"$search": {"range": {"path": "num", "gte": 10, "doesNotAffect": "score"}}}], + expected={"cursor.firstBatch": [Len(2), Contains("_id", 4), Contains("_id", 5)]}, + msg="$search range should accept a string doesNotAffect option and still return its " + "matches", + ), +] + +SEARCH_RANGE_TESTS = ( + SEARCH_RANGE_NUMERIC_BOUND_TESTS + + SEARCH_RANGE_DATE_BOUND_TESTS + + SEARCH_RANGE_INCLUSIVE_EXCLUSIVE_TESTS + + SEARCH_RANGE_NON_FINITE_TESTS + + SEARCH_RANGE_TYPE_MISMATCH_TESTS + + SEARCH_RANGE_LEXICOGRAPHIC_ORDER_TESTS + + SEARCH_RANGE_STRING_INCLUSIVE_EXCLUSIVE_TESTS + + SEARCH_RANGE_EMPTY_STRING_BOUND_TESTS + + SEARCH_RANGE_OBJECTID_BOUND_TESTS + + SEARCH_RANGE_DOES_NOT_AFFECT_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCH_RANGE_TESTS)) +def test_search_range_cases(range_collection, test_case: StageTestCase): + """Test $search range numeric, datetime, and lexicographic string bound semantics.""" + result = execute_command( + range_collection, + {"aggregate": range_collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult( + result, + expected=test_case.expected, + msg=test_case.msg, + raw_res=True, + ) + + +# Property [Range Bound Unsupported Type]: number, string, date, and ObjectId are +# the supported bound types, so a bound of any other type is rejected regardless +# of the path type. +SEARCH_RANGE_BOUND_TYPE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"bound_type_{tid}", + pipeline=[ + {"$search": {"range": {"path": "num", "gte": val}}}, + ], + error_code=UNKNOWN_ERROR, + msg=f"$search range should reject a {tid} bound as an unsupported type", + ) + for tid, val in [ + ("bool", True), + ("object", {"a": 1}), + ("array", [1, 2]), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("null", None), + ] +] + +# Property [Range String Bound On Date Path]: a string bound on a date path is +# rejected as needing a token index, unlike a string bound on a numeric path +# which returns no documents with no error. +SEARCH_RANGE_STRING_BOUND_DATE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "string_bound_date_path", + pipeline=[ + {"$search": {"range": {"path": "dt", "gte": "2020-01-01"}}}, + ], + error_code=UNKNOWN_ERROR, + msg="$search range should reject a string bound on a date path as needing a token index", + ), +] + +# Property [Range Required Path]: range.path is required, so a spec omitting it +# (even with a valid bound) is rejected. +SEARCH_RANGE_REQUIRED_PATH_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "range_path_missing", + pipeline=[{"$search": {"range": {"gte": 5}}}], + error_code=UNKNOWN_ERROR, + msg="$search range should reject a spec that omits the required path", + ), +] + +# Property [Range Requires A Bound]: a range specifying none of lt/lte/gt/gte is +# rejected. +SEARCH_RANGE_NO_BOUND_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "no_bound", + pipeline=[{"$search": {"range": {"path": "num"}}}], + error_code=UNKNOWN_ERROR, + msg="$search range should reject a spec that specifies no bound", + ), +] + +# Property [Range Single Bound Per Direction]: specifying both bounds for one +# direction (gt and gte, or lt and lte) is rejected. +SEARCH_RANGE_DUPLICATE_BOUND_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "duplicate_lower_bound", + pipeline=[ + {"$search": {"range": {"path": "num", "gt": 0, "gte": 0}}}, + ], + error_code=UNKNOWN_ERROR, + msg="$search range should reject specifying both gt and gte", + ), + StageTestCase( + "duplicate_upper_bound", + pipeline=[ + {"$search": {"range": {"path": "num", "lt": 10, "lte": 10}}}, + ], + error_code=UNKNOWN_ERROR, + msg="$search range should reject specifying both lt and lte", + ), +] + +# Property [Range Interval Validity]: an inverted interval (gte greater than lte) +# and an exclusive degenerate interval (gt equal to lt) are rejected. +SEARCH_RANGE_INTERVAL_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "inverted_interval", + pipeline=[ + {"$search": {"range": {"path": "num", "gte": 10, "lte": 5}}}, + ], + error_code=UNKNOWN_ERROR, + msg="$search range should reject an inverted interval where gte exceeds lte", + ), + StageTestCase( + "exclusive_degenerate_interval", + pipeline=[ + {"$search": {"range": {"path": "num", "gt": 5, "lt": 5}}}, + ], + error_code=UNKNOWN_ERROR, + msg="$search range should reject an exclusive degenerate interval where gt equals lt", + ), +] + +SEARCH_RANGE_ERROR_TESTS = ( + SEARCH_RANGE_BOUND_TYPE_ERROR_TESTS + + SEARCH_RANGE_STRING_BOUND_DATE_ERROR_TESTS + + SEARCH_RANGE_REQUIRED_PATH_ERROR_TESTS + + SEARCH_RANGE_NO_BOUND_ERROR_TESTS + + SEARCH_RANGE_DUPLICATE_BOUND_ERROR_TESTS + + SEARCH_RANGE_INTERVAL_ERROR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCH_RANGE_ERROR_TESTS)) +def test_search_range_errors(range_collection, test_case: StageTestCase): + """Test $search range rejects unsupported bound types and invalid bound intervals.""" + result = execute_command( + range_collection, + {"aggregate": range_collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult(result, error_code=test_case.error_code, msg=test_case.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_regex.py b/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_regex.py new file mode 100644 index 000000000..7f3e40d35 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_regex.py @@ -0,0 +1,319 @@ +"""Tests for the $search regex operator.""" + +from __future__ import annotations + +import datetime + +import pytest +from bson import Binary, Code, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + UNKNOWN_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import ( + Contains, + Len, +) +from documentdb_tests.framework.test_constants import ( + DECIMAL128_ONE_AND_HALF, + REGEX_PATTERN_LIMIT_BYTES, +) + +pytestmark = pytest.mark.requires(search=True) + + +# Property [Regex Analyzed Path Matching]: with allowAnalyzedField true, the regex +# operator matches against an analyzed path's tokens, returning the documents +# whose token satisfies the pattern. +SEARCH_REGEX_MATCHING_TESTS: list[StageTestCase] = [ + StageTestCase( + "regex_analyzed_prefix", + pipeline=[ + {"$search": {"regex": {"query": "qu.*", "path": "title", "allowAnalyzedField": True}}}, + ], + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 1), + Contains("_id", 3), + Contains("_id", 4), + ] + }, + msg="$search regex should match analyzed-path tokens against the pattern when " + "allowAnalyzedField is true", + ), + StageTestCase( + "regex_score_boost", + pipeline=[ + { + "$search": { + "regex": { + "query": "qu.*", + "path": "title", + "allowAnalyzedField": True, + "score": {"boost": {"value": 2.0}}, + } + } + }, + ], + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 1), + Contains("_id", 3), + Contains("_id", 4), + ] + }, + msg="$search regex should accept a score modifier and still return its matches", + ), + StageTestCase( + "regex_analyzed_distinct_token", + pipeline=[ + {"$search": {"regex": {"query": "tur.*", "path": "title", "allowAnalyzedField": True}}}, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 2)]}, + msg="$search regex should match only the documents whose analyzed token satisfies " + "the pattern", + ), +] + +# Property [Regex Pattern Length]: the regex operator imposes no byte-based +# pattern-length limit. +SEARCH_REGEX_PATTERN_LENGTH_TESTS: list[StageTestCase] = [ + StageTestCase( + f"regex_pattern_length_{n}", + pipeline=[ + {"$search": {"regex": {"query": "a" * n, "path": "title", "allowAnalyzedField": True}}}, + ], + expected={"cursor.firstBatch": Len(0)}, + msg=f"$search regex should accept a {n}-byte pattern with no byte-based length limit", + ) + for n in [ + REGEX_PATTERN_LIMIT_BYTES - 1, + REGEX_PATTERN_LIMIT_BYTES, + REGEX_PATTERN_LIMIT_BYTES + 1, + 100_000, + ] +] + +# Property [Regex Query Array OR]: regex.query accepts an array of patterns, matching +# the union of the documents matched by each element pattern. +SEARCH_REGEX_QUERY_ARRAY_TESTS: list[StageTestCase] = [ + StageTestCase( + "regex_query_array_or", + pipeline=[ + { + "$search": { + "regex": { + "query": ["qu.*", "tur.*"], + "path": "title", + "allowAnalyzedField": True, + } + } + }, + ], + expected={ + "cursor.firstBatch": [ + Len(4), + Contains("_id", 1), + Contains("_id", 2), + Contains("_id", 3), + Contains("_id", 4), + ] + }, + msg="$search regex should match the union of a multi-element query array's patterns", + ), +] + +SEARCH_REGEX_TESTS = ( + SEARCH_REGEX_MATCHING_TESTS + SEARCH_REGEX_PATTERN_LENGTH_TESTS + SEARCH_REGEX_QUERY_ARRAY_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCH_REGEX_TESTS)) +def test_search_regex_cases(indexed_collection, test_case: StageTestCase): + """Test $search regex matching and pattern length.""" + result = execute_command( + indexed_collection, + {"aggregate": indexed_collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult( + result, + expected=test_case.expected, + msg=test_case.msg, + raw_res=True, + ) + + +# Property [Regex Analyzed Path Rejection]: regex rejects a path that resolves to +# an analyzed (non-keyword) field unless allowAnalyzedField is true. +SEARCH_REGEX_ANALYZED_PATH_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "regex_analyzed_path_no_flag", + pipeline=[ + {"$search": {"regex": {"query": "qu.*", "path": "title"}}}, + ], + error_code=UNKNOWN_ERROR, + msg="$search regex should reject an analyzed path when allowAnalyzedField is omitted", + ), +] + +# Property [Regex query Validation]: regex.query is required and must be a string +# or a non-empty array of non-null strings. +SEARCH_REGEX_QUERY_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "regex_query_missing", + pipeline=[{"$search": {"regex": {"path": "title", "allowAnalyzedField": True}}}], + error_code=UNKNOWN_ERROR, + msg="$search regex should reject an operator missing the required query", + ), + *[ + StageTestCase( + f"regex_query_non_string_{tid}", + pipeline=[ + {"$search": {"regex": {"query": val, "path": "title", "allowAnalyzedField": True}}}, + ], + error_code=UNKNOWN_ERROR, + msg=f"$search regex should reject a {tid} query as a non-string", + ) + for tid, val in [ + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("bool", True), + ("object", {"q": "qu.*"}), + ("objectid", ObjectId("0123456789abcdef01234567")), + ("datetime", datetime.datetime(2020, 1, 1)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ] + ], + StageTestCase( + "regex_query_empty_array", + pipeline=[ + {"$search": {"regex": {"query": [], "path": "title", "allowAnalyzedField": True}}} + ], + error_code=UNKNOWN_ERROR, + msg="$search regex should reject an empty-array query", + ), + StageTestCase( + "regex_query_array_element_null", + pipeline=[ + { + "$search": { + "regex": {"query": ["qu.*", None], "path": "title", "allowAnalyzedField": True} + } + }, + ], + error_code=UNKNOWN_ERROR, + msg="$search regex should reject a null query-array element", + ), + StageTestCase( + "regex_query_array_element_non_string", + pipeline=[ + { + "$search": { + "regex": {"query": ["qu.*", 1], "path": "title", "allowAnalyzedField": True} + } + }, + ], + error_code=UNKNOWN_ERROR, + msg="$search regex should reject a non-string query-array element", + ), +] + +# Property [Regex allowAnalyzedField Type]: allowAnalyzedField must be a boolean. +SEARCH_REGEX_ALLOW_ANALYZED_TYPE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"regex_allow_analyzed_{tid}", + pipeline=[ + {"$search": {"regex": {"query": "qu.*", "path": "title", "allowAnalyzedField": val}}}, + ], + error_code=UNKNOWN_ERROR, + msg=f"$search regex should reject a {tid} allowAnalyzedField as a non-boolean", + ) + for tid, val in [ + ("string", "true"), + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("object", {"a": 1}), + ("array", [True]), + ("objectid", ObjectId("0123456789abcdef01234567")), + ("datetime", datetime.datetime(2020, 1, 1)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ] +] + +# Property [Regex path Validation]: regex.path is required and must be a string, +# document, or array of paths. +SEARCH_REGEX_PATH_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "regex_path_missing", + pipeline=[{"$search": {"regex": {"query": "qu.*", "allowAnalyzedField": True}}}], + error_code=UNKNOWN_ERROR, + msg="$search regex should reject an operator missing the required path", + ), + *[ + StageTestCase( + f"regex_path_{tid}", + pipeline=[ + {"$search": {"regex": {"query": "qu.*", "path": val, "allowAnalyzedField": True}}}, + ], + error_code=UNKNOWN_ERROR, + msg=f"$search regex should reject a {tid} path as neither a string, document, " + "nor array", + ) + for tid, val in [ + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("bool", True), + ("objectid", ObjectId("0123456789abcdef01234567")), + ("datetime", datetime.datetime(2020, 1, 1)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ] + ], +] + +SEARCH_REGEX_ERROR_TESTS = ( + SEARCH_REGEX_ANALYZED_PATH_ERROR_TESTS + + SEARCH_REGEX_QUERY_ERROR_TESTS + + SEARCH_REGEX_ALLOW_ANALYZED_TYPE_ERROR_TESTS + + SEARCH_REGEX_PATH_ERROR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCH_REGEX_ERROR_TESTS)) +def test_search_regex_errors(indexed_collection, test_case: StageTestCase): + """Test $search regex rejects analyzed paths and bad query/allowAnalyzedField/path values.""" + result = execute_command( + indexed_collection, + {"aggregate": indexed_collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult(result, error_code=test_case.error_code, msg=test_case.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_return_scope.py b/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_return_scope.py new file mode 100644 index 000000000..25e254aa1 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_return_scope.py @@ -0,0 +1,125 @@ +"""Tests for the $search returnScope option against embedded-document scopes.""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.stages.search.utils.search_common import ( + create_search_index, +) +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, +) +from documentdb_tests.framework import fixtures +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import UNKNOWN_ERROR +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Contains, Len + +pytestmark = pytest.mark.requires(search=True) + +_SCOPE_DOCS = [ + { + "_id": 1, + "groups": [ + {"gid": "g1", "tags": [{"name": "quick"}, {"name": "slow"}]}, + {"gid": "g2", "tags": [{"name": "lazy"}]}, + ], + }, + {"_id": 2, "groups": [{"gid": "g3", "tags": [{"name": "quick"}]}]}, + {"_id": 3, "groups": [{"gid": "g4", "tags": [{"name": "lazy"}]}]}, +] + +# The scope path (groups) must be indexed as embeddedDocuments with a non-empty +# storedSource for returnScope to reshape results to it. +_SCOPE_INDEX_DEFINITION = { + "mappings": { + "dynamic": False, + "fields": { + "groups": { + "type": "embeddedDocuments", + "storedSource": True, + "fields": { + "gid": {"type": "token"}, + "tags": {"type": "embeddedDocuments", "dynamic": True}, + }, + } + }, + } +} + + +@pytest.fixture(scope="module") +def scoped_collection(engine_client, worker_id): + """A module-scoped collection with nested embeddedDocuments (groups -> tags) + and a stored source on groups, so returnScope can return the matched group + scope rather than the whole parent document.""" + db_name = fixtures.generate_database_name("stages_search_return_scope", worker_id) + fixtures.cleanup_database(engine_client, db_name) + db = engine_client[db_name] + coll = db["scoped"] + coll.insert_many(_SCOPE_DOCS) + create_search_index(coll, _SCOPE_INDEX_DEFINITION) + yield coll + fixtures.cleanup_database(engine_client, db_name) + + +_EMBEDDED_OPERATOR = { + "embeddedDocument": { + "path": "groups.tags", + "operator": {"text": {"query": "quick", "path": "groups.tags.name"}}, + } +} + +# Property [returnScope Result Shape]: a non-empty returnScope whose path is an +# ancestor of the operator path returns the matched scope sub-documents in place +# of the parent documents. +SEARCH_RETURN_SCOPE_SUCCESS_TESTS: list[StageTestCase] = [ + StageTestCase( + "return_scope_returns_matched_scopes", + pipeline=[ + { + "$search": { + **_EMBEDDED_OPERATOR, + "returnStoredSource": True, + "returnScope": {"path": "groups"}, + } + } + ], + expected={"cursor.firstBatch": [Len(2), Contains("gid", "g1"), Contains("gid", "g3")]}, + msg="$search returnScope should return only the matched group scopes, not the parent " + "documents", + ), +] + +# Property [returnScope Requires Stored Source]: a non-empty returnScope is +# rejected unless returnStoredSource is enabled, even when the scope path is +# correctly indexed. +SEARCH_RETURN_SCOPE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "return_scope_without_return_stored_source", + pipeline=[{"$search": {**_EMBEDDED_OPERATOR, "returnScope": {"path": "groups"}}}], + error_code=UNKNOWN_ERROR, + msg="$search should reject a non-empty returnScope when returnStoredSource is not enabled", + ), +] + +SEARCH_RETURN_SCOPE_TESTS = SEARCH_RETURN_SCOPE_SUCCESS_TESTS + SEARCH_RETURN_SCOPE_ERROR_TESTS + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCH_RETURN_SCOPE_TESTS)) +def test_search_return_scope_cases(scoped_collection, test_case: StageTestCase): + """Test $search returnScope result reshaping and its returnStoredSource requirement.""" + result = execute_command( + scoped_collection, + {"aggregate": scoped_collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_score.py b/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_score.py new file mode 100644 index 000000000..fb3915e5e --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_score.py @@ -0,0 +1,306 @@ +"""Tests for $search score ordering and scoreDetails.""" + +from __future__ import annotations + +import datetime + +import pytest +from bson import Binary, Code, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BSON_FIELD_NOT_BOOL_ERROR, + QUERY_METADATA_NOT_AVAILABLE_ERROR, + UNKNOWN_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import ( + Contains, + Eq, + Exists, + Gt, + Len, + NonEmptyStr, + PerDoc, +) +from documentdb_tests.framework.test_constants import ( + DECIMAL128_ONE_AND_HALF, +) + +pytestmark = pytest.mark.requires(search=True) + + +# Property [SearchScore Ordering]: results are returned in descending searchScore +# order so the document with the highest matching-term frequency ranks first, and +# {$meta: "searchScore"} projects a positive float for every result. +SEARCH_SCORE_TESTS: list[StageTestCase] = [ + StageTestCase( + "score_term_frequency_ordering", + pipeline=[ + {"$search": {"text": {"query": "quick", "path": "title"}}}, + {"$project": {"_id": 1, "score": {"$meta": "searchScore"}}}, + ], + expected=PerDoc( + {"_id": Eq(3), "score": Gt(0)}, + {"_id": Eq(4), "score": Gt(0)}, + {"_id": Eq(1), "score": Gt(0)}, + ), + msg="$search should order results by descending searchScore, ranking the " + "highest term-frequency document first", + ), +] + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCH_SCORE_TESTS)) +def test_search_score_ordering(indexed_collection, test_case: StageTestCase): + """Test $search orders results by descending searchScore.""" + result = execute_command( + indexed_collection, + {"aggregate": indexed_collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult(result, expected=test_case.expected, msg=test_case.msg) + + +# Property [ScoreDetails Output]: with scoreDetails enabled, {$meta: "scoreDetails"} +# projects a recursive object with a positive value, a populated description +# string, and a populated details array. +SEARCH_SCORE_DETAILS_TESTS: list[StageTestCase] = [ + StageTestCase( + "score_details_shape", + pipeline=[ + {"$search": {"text": {"query": "quick", "path": "title"}, "scoreDetails": True}}, + {"$limit": 1}, + {"$project": {"_id": 0, "sd": {"$meta": "scoreDetails"}}}, + ], + expected={ + "sd.value": Gt(0), + "sd.description": NonEmptyStr(), + "sd.details.0": Exists(), + }, + msg="$search should project a recursive scoreDetails object with a value, " + "description, and details array", + ), +] + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCH_SCORE_DETAILS_TESTS)) +def test_search_score_details(indexed_collection, test_case: StageTestCase): + """Test $search projects the recursive scoreDetails shape (value, description, details).""" + result = execute_command( + indexed_collection, + {"aggregate": indexed_collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult(result, expected=test_case.expected, msg=test_case.msg) + + +# Property [ScoreDetails Unavailable]: projecting {$meta: "scoreDetails"} without +# enabling scoreDetails on the $search stage is rejected because the metadata is +# not computed. +SEARCH_SCORE_DETAILS_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "score_details_not_enabled", + pipeline=[ + {"$search": {"text": {"query": "quick", "path": "title"}}}, + {"$project": {"_id": 1, "sd": {"$meta": "scoreDetails"}}}, + ], + error_code=QUERY_METADATA_NOT_AVAILABLE_ERROR, + msg="$search should reject a scoreDetails metadata projection when scoreDetails " + "is not enabled", + ), +] + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCH_SCORE_DETAILS_ERROR_TESTS)) +def test_search_score_details_unavailable(indexed_collection, test_case: StageTestCase): + """Test $search rejects a scoreDetails projection when scoreDetails is not enabled.""" + result = execute_command( + indexed_collection, + {"aggregate": indexed_collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult(result, error_code=test_case.error_code, msg=test_case.msg) + + +# Property [ScoreDetails Boolean Type]: the scoreDetails option is strictly +# boolean with no coercion, and a null is not treated as a missing value. +SEARCH_SCORE_DETAILS_BOOL_TYPE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"score_details_bool_type_{tid}", + pipeline=[ + {"$search": {"text": {"query": "quick", "path": "title"}, "scoreDetails": val}}, + ], + error_code=BSON_FIELD_NOT_BOOL_ERROR, + msg=f"$search should reject a {tid} scoreDetails as a non-boolean", + ) + for tid, val in [ + ("string", "true"), + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("object", {"a": 1}), + ("array", [True]), + ("objectid", ObjectId("0123456789abcdef01234567")), + ("datetime", datetime.datetime(2020, 1, 1)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("null", None), + ] +] + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCH_SCORE_DETAILS_BOOL_TYPE_ERROR_TESTS)) +def test_search_score_details_bool_error(indexed_collection, test_case: StageTestCase): + """Test $search rejects a non-bool scoreDetails option value.""" + result = execute_command( + indexed_collection, + {"aggregate": indexed_collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult(result, error_code=test_case.error_code, msg=test_case.msg) + + +# Property [Operator Score Modifier]: an operator score modifier accepts exactly +# one of boost, constant, or function. Each alters scoring without changing the +# matched set, so the search still returns its matches. This is a shared operator +# option (every operator accepts it), so it is covered once here rather than per +# operator. +SEARCH_SCORE_MODIFIER_TESTS: list[StageTestCase] = [ + StageTestCase( + "score_modifier_boost", + pipeline=[ + { + "$search": { + "text": { + "query": "quick", + "path": "title", + "score": {"boost": {"value": 2.0}}, + } + } + }, + ], + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 1), + Contains("_id", 3), + Contains("_id", 4), + ] + }, + msg="$search should accept a boost score modifier and still return its matches", + ), + StageTestCase( + "score_modifier_constant", + pipeline=[ + { + "$search": { + "text": { + "query": "quick", + "path": "title", + "score": {"constant": {"value": 5.0}}, + } + } + }, + ], + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 1), + Contains("_id", 3), + Contains("_id", 4), + ] + }, + msg="$search should accept a constant score modifier and still return its matches", + ), + StageTestCase( + "score_modifier_function", + pipeline=[ + { + "$search": { + "text": { + "query": "quick", + "path": "title", + "score": {"function": {"score": "relevance"}}, + } + } + }, + ], + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 1), + Contains("_id", 3), + Contains("_id", 4), + ] + }, + msg="$search should accept a function score modifier and still return its matches", + ), +] + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCH_SCORE_MODIFIER_TESTS)) +def test_search_score_modifier(indexed_collection, test_case: StageTestCase): + """Test $search accepts each operator score modifier variant.""" + result = execute_command( + indexed_collection, + {"aggregate": indexed_collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult( + result, + expected=test_case.expected, + msg=test_case.msg, + raw_res=True, + ) + + +# Property [Operator Score Modifier Validity]: a score modifier must name exactly +# one of boost/constant/function, so an empty modifier and one naming more than +# one variant are each rejected. +SEARCH_SCORE_MODIFIER_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "score_modifier_empty", + pipeline=[ + {"$search": {"text": {"query": "quick", "path": "title", "score": {}}}}, + ], + error_code=UNKNOWN_ERROR, + msg="$search should reject an empty score modifier naming no variant", + ), + StageTestCase( + "score_modifier_multiple_variants", + pipeline=[ + { + "$search": { + "text": { + "query": "quick", + "path": "title", + "score": {"boost": {"value": 2.0}, "constant": {"value": 5.0}}, + } + } + }, + ], + error_code=UNKNOWN_ERROR, + msg="$search should reject a score modifier naming more than one variant", + ), +] + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCH_SCORE_MODIFIER_ERROR_TESTS)) +def test_search_score_modifier_errors(indexed_collection, test_case: StageTestCase): + """Test $search rejects an empty or multi-variant score modifier.""" + result = execute_command( + indexed_collection, + {"aggregate": indexed_collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult(result, error_code=test_case.error_code, msg=test_case.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_span.py b/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_span.py new file mode 100644 index 000000000..204b630c5 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_span.py @@ -0,0 +1,544 @@ +"""Tests for the $search span operator.""" + +from __future__ import annotations + +import datetime + +import pytest +from bson import Binary, Code, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + UNKNOWN_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import ( + Contains, + Len, +) +from documentdb_tests.framework.test_constants import ( + DECIMAL128_ONE_AND_HALF, +) + +pytestmark = pytest.mark.requires(search=True) + + +# Property [Span Sub-operator Execution]: span sub-operators execute positional +# and proximity matches against an analyzed path. +SEARCH_SPAN_TESTS: list[StageTestCase] = [ + StageTestCase( + "span_term_token_match", + pipeline=[ + {"$search": {"span": {"term": {"path": "title", "query": "quick"}}}}, + ], + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 1), + Contains("_id", 3), + Contains("_id", 4), + ] + }, + msg="$search span term should execute a token match over the analyzed path", + ), + StageTestCase( + "span_first_positional", + # first bounds the span's end position, so only a token at the start of + # the field matches; doc 4's "$quick" tokenizes to "quick" at position 0 + # while docs 1 and 3 carry "quick" later in the field. + pipeline=[ + { + "$search": { + "span": { + "first": { + "operator": {"term": {"path": "title", "query": "quick"}}, + "endPositionLte": 1, + } + } + } + }, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 4)]}, + msg="$search span first should match only spans ending within the position bound", + ), + StageTestCase( + "span_near_adjacent", + pipeline=[ + { + "$search": { + "span": { + "near": { + "clauses": [ + {"term": {"path": "title", "query": "quick"}}, + {"term": {"path": "title", "query": "brown"}}, + ], + "slop": 0, + "inOrder": True, + } + } + } + }, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 1)]}, + msg="$search span near with slop 0 should match adjacent in-order spans", + ), + StageTestCase( + "span_near_slop_excludes_gap", + pipeline=[ + { + "$search": { + "span": { + "near": { + "clauses": [ + {"term": {"path": "title", "query": "quick"}}, + {"term": {"path": "title", "query": "fox"}}, + ], + "slop": 0, + "inOrder": True, + } + } + } + }, + ], + expected={"cursor.firstBatch": Len(0)}, + msg="$search span near with slop 0 should exclude spans separated by an " + "intervening token", + ), + StageTestCase( + "span_near_slop_permits_gap", + pipeline=[ + { + "$search": { + "span": { + "near": { + "clauses": [ + {"term": {"path": "title", "query": "quick"}}, + {"term": {"path": "title", "query": "fox"}}, + ], + "slop": 1, + "inOrder": True, + } + } + } + }, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 1)]}, + msg="$search span near should permit an intervening token within the slop bound", + ), + StageTestCase( + "span_near_order_enforced", + pipeline=[ + { + "$search": { + "span": { + "near": { + "clauses": [ + {"term": {"path": "title", "query": "brown"}}, + {"term": {"path": "title", "query": "quick"}}, + ], + "slop": 0, + "inOrder": True, + } + } + } + }, + ], + expected={"cursor.firstBatch": Len(0)}, + msg="$search span near with inOrder should not match clauses in the reversed order", + ), + StageTestCase( + "span_or_union", + pipeline=[ + { + "$search": { + "span": { + "or": { + "clauses": [ + {"term": {"path": "title", "query": "quick"}}, + {"term": {"path": "title", "query": "turtle"}}, + ] + } + } + } + }, + ], + expected={ + "cursor.firstBatch": [ + Len(4), + Contains("_id", 1), + Contains("_id", 2), + Contains("_id", 3), + Contains("_id", 4), + ] + }, + msg="$search span or should match the union of its clause spans", + ), + StageTestCase( + "span_subtract_excludes_overlap", + # subtract removes include spans that overlap an exclude span, so + # subtracting the same token's spans from themselves leaves nothing. + pipeline=[ + { + "$search": { + "span": { + "subtract": { + "include": {"term": {"path": "title", "query": "quick"}}, + "exclude": {"term": {"path": "title", "query": "quick"}}, + } + } + } + }, + ], + expected={"cursor.firstBatch": Len(0)}, + msg="$search span subtract should remove include spans that overlap the exclude spans", + ), + StageTestCase( + "span_contains_inner", + # little=quick is contained by the big quick-brown span, so spanToReturn + # inner returns the document carrying that containment. + pipeline=[ + { + "$search": { + "span": { + "contains": { + "little": {"term": {"path": "title", "query": "quick"}}, + "big": { + "near": { + "clauses": [ + {"term": {"path": "title", "query": "quick"}}, + {"term": {"path": "title", "query": "brown"}}, + ], + "slop": 0, + "inOrder": True, + } + }, + "spanToReturn": "inner", + } + } + } + }, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 1)]}, + msg="$search span contains spanToReturn inner should return the document whose little " + "span is contained by the big span", + ), + StageTestCase( + "span_contains_outer", + pipeline=[ + { + "$search": { + "span": { + "contains": { + "little": {"term": {"path": "title", "query": "quick"}}, + "big": { + "near": { + "clauses": [ + {"term": {"path": "title", "query": "quick"}}, + {"term": {"path": "title", "query": "brown"}}, + ], + "slop": 0, + "inOrder": True, + } + }, + "spanToReturn": "outer", + } + } + } + }, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 1)]}, + msg="$search span contains spanToReturn outer should return the document whose big span " + "contains the little span", + ), + StageTestCase( + "span_contains_excludes_non_contained", + # little=turtle is not contained by the big quick-brown span, so the + # containment constraint excludes every document. + pipeline=[ + { + "$search": { + "span": { + "contains": { + "little": {"term": {"path": "title", "query": "turtle"}}, + "big": { + "near": { + "clauses": [ + {"term": {"path": "title", "query": "quick"}}, + {"term": {"path": "title", "query": "brown"}}, + ], + "slop": 0, + "inOrder": True, + } + }, + "spanToReturn": "inner", + } + } + } + }, + ], + expected={"cursor.firstBatch": Len(0)}, + msg="$search span contains should exclude a document whose little span is not contained " + "by the big span", + ), +] + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCH_SPAN_TESTS)) +def test_search_span_cases(indexed_collection, test_case: StageTestCase): + """Test $search span single sub-operator execution.""" + result = execute_command( + indexed_collection, + {"aggregate": indexed_collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult( + result, + expected=test_case.expected, + msg=test_case.msg, + raw_res=True, + ) + + +# Property [Span Operator Required]: a span document with no sub-operator key +# produces a spec validation error. +SEARCH_SPAN_OPERATOR_REQUIRED_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "span_empty", + pipeline=[{"$search": {"span": {}}}], + error_code=UNKNOWN_ERROR, + msg="$search span should reject an empty document with no sub-operator", + ), +] + +# Property [Span Sub-operator Required Sub-fields]: a span sub-operator with a +# required sub-field omitted is rejected. +SEARCH_SPAN_SUBOPERATOR_REQUIRED_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "term_missing_path", + pipeline=[{"$search": {"span": {"term": {"query": "quick"}}}}], + error_code=UNKNOWN_ERROR, + msg="$search span term should reject a missing path", + ), + StageTestCase( + "term_missing_query", + pipeline=[{"$search": {"span": {"term": {"path": "title"}}}}], + error_code=UNKNOWN_ERROR, + msg="$search span term should reject a missing query", + ), + StageTestCase( + "first_missing_operator", + pipeline=[ + {"$search": {"span": {"first": {"endPositionLte": 1}}}}, + ], + error_code=UNKNOWN_ERROR, + msg="$search span first should reject a missing operator", + ), + StageTestCase( + "near_missing_clauses", + pipeline=[{"$search": {"span": {"near": {"slop": 0}}}}], + error_code=UNKNOWN_ERROR, + msg="$search span near should reject missing clauses", + ), + StageTestCase( + "or_missing_clauses", + pipeline=[{"$search": {"span": {"or": {}}}}], + error_code=UNKNOWN_ERROR, + msg="$search span or should reject missing clauses", + ), + StageTestCase( + "subtract_missing_include", + pipeline=[ + { + "$search": { + "span": {"subtract": {"exclude": {"term": {"path": "title", "query": "quick"}}}} + } + }, + ], + error_code=UNKNOWN_ERROR, + msg="$search span subtract should reject a missing include", + ), + StageTestCase( + "subtract_missing_exclude", + pipeline=[ + { + "$search": { + "span": {"subtract": {"include": {"term": {"path": "title", "query": "quick"}}}} + } + }, + ], + error_code=UNKNOWN_ERROR, + msg="$search span subtract should reject a missing exclude", + ), + StageTestCase( + "contains_missing_little", + pipeline=[ + { + "$search": { + "span": { + "contains": { + "big": {"term": {"path": "title", "query": "quick"}}, + "spanToReturn": "inner", + } + } + } + }, + ], + error_code=UNKNOWN_ERROR, + msg="$search span contains should reject a missing little", + ), + StageTestCase( + "contains_missing_span_to_return", + pipeline=[ + { + "$search": { + "span": { + "contains": { + "little": {"term": {"path": "title", "query": "quick"}}, + "big": {"term": {"path": "title", "query": "brown"}}, + } + } + } + }, + ], + error_code=UNKNOWN_ERROR, + msg="$search span contains should reject a missing spanToReturn", + ), + StageTestCase( + "contains_missing_big", + pipeline=[ + { + "$search": { + "span": { + "contains": { + "little": {"term": {"path": "title", "query": "quick"}}, + "spanToReturn": "inner", + } + } + } + }, + ], + error_code=UNKNOWN_ERROR, + msg="$search span contains should reject a missing big", + ), +] + +# Property [Span Contains spanToReturn Enum]: span.contains.spanToReturn accepts +# only the values inner or outer, so a value outside that set is rejected. +SEARCH_SPAN_CONTAINS_ENUM_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "contains_span_to_return_bogus", + pipeline=[ + { + "$search": { + "span": { + "contains": { + "little": {"term": {"path": "title", "query": "quick"}}, + "big": {"term": {"path": "title", "query": "brown"}}, + "spanToReturn": "bogus", + } + } + } + }, + ], + error_code=UNKNOWN_ERROR, + msg="$search span contains should reject a spanToReturn outside the [inner, outer] enum", + ), +] + +# Property [Span Near Sub-field Type Rejection]: a near sub-operator's non-integer +# slop or non-boolean inOrder is rejected with no coercion. +SEARCH_SPAN_NEAR_TYPE_ERROR_TESTS: list[StageTestCase] = [ + *[ + StageTestCase( + f"near_slop_type_{tid}", + pipeline=[ + { + "$search": { + "span": { + "near": { + "clauses": [{"term": {"path": "title", "query": "quick"}}], + "slop": val, + } + } + } + }, + ], + error_code=UNKNOWN_ERROR, + msg=f"$search span near should reject a {tid} slop as a non-integer", + ) + for tid, val in [ + ("string", "1"), + ("double", 1.5), + ("bool", True), + ("object", {"a": 1}), + ("array", [1]), + ("objectid", ObjectId("0123456789abcdef01234567")), + ("datetime", datetime.datetime(2020, 1, 1)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ] + ], + *[ + StageTestCase( + f"near_inorder_type_{tid}", + pipeline=[ + { + "$search": { + "span": { + "near": { + "clauses": [{"term": {"path": "title", "query": "quick"}}], + "inOrder": val, + } + } + } + }, + ], + error_code=UNKNOWN_ERROR, + msg=f"$search span near should reject a {tid} inOrder as a non-boolean", + ) + for tid, val in [ + ("string", "true"), + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("object", {"a": 1}), + ("array", [True]), + ("objectid", ObjectId("0123456789abcdef01234567")), + ("datetime", datetime.datetime(2020, 1, 1)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ] + ], +] + +SEARCH_SPAN_ERROR_TESTS = ( + SEARCH_SPAN_OPERATOR_REQUIRED_ERROR_TESTS + + SEARCH_SPAN_SUBOPERATOR_REQUIRED_ERROR_TESTS + + SEARCH_SPAN_CONTAINS_ENUM_ERROR_TESTS + + SEARCH_SPAN_NEAR_TYPE_ERROR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCH_SPAN_ERROR_TESTS)) +def test_search_span_errors(indexed_collection, test_case: StageTestCase): + """Test $search span rejects an empty operator, missing sub-fields, and mistyped near fields.""" + result = execute_command( + indexed_collection, + {"aggregate": indexed_collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult(result, error_code=test_case.error_code, msg=test_case.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_spec_errors.py b/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_spec_errors.py new file mode 100644 index 000000000..ec8d03dec --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_spec_errors.py @@ -0,0 +1,164 @@ +"""Tests for $search stage spec and operator structural errors.""" + +from __future__ import annotations + +import datetime + +import pytest +from bson import Binary, Code, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.stages.search.utils.search_common import ( + SEARCH_INDEX_NAME, +) +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + UNKNOWN_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_ONE_AND_HALF, +) + +pytestmark = pytest.mark.requires(search=True) + + +# Property [Null Sub-field As Missing]: a null operator value or null required +# text sub-field (path/query) is treated as missing and hits the same +# downstream required-field error as omitting it entirely. +SEARCH_NULL_MISSING_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "null_missing_operator", + pipeline=[{"$search": {"text": None}}], + error_code=UNKNOWN_ERROR, + msg="$search should treat a null operator value as a missing operator and reject it", + ), + StageTestCase( + "null_missing_text_query", + pipeline=[ + {"$search": {"text": {"query": None, "path": "title"}}}, + ], + error_code=UNKNOWN_ERROR, + msg="$search should treat a null text.query as missing and reject the required query", + ), + StageTestCase( + "null_missing_text_path", + pipeline=[ + {"$search": {"text": {"query": "quick", "path": None}}}, + ], + error_code=UNKNOWN_ERROR, + msg="$search should treat a null text.path as missing and reject the required path", + ), +] + +# Property [Operator Slot Missing]: a spec containing no recognized search +# operator (empty, options-only, or an unknown operator key) is rejected. +SEARCH_OPERATOR_MISSING_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "operator_missing_empty_spec", + pipeline=[{"$search": {}}], + error_code=UNKNOWN_ERROR, + msg="$search should reject an empty spec that contains no operator", + ), + StageTestCase( + "operator_missing_options_only", + pipeline=[{"$search": {"index": SEARCH_INDEX_NAME}}], + error_code=UNKNOWN_ERROR, + msg="$search should reject an options-only spec that contains no operator", + ), + StageTestCase( + "operator_missing_unknown_key", + pipeline=[ + {"$search": {"bogus": {"query": "quick", "path": "title"}}}, + ], + error_code=UNKNOWN_ERROR, + msg="$search should reject an unknown operator key as no recognized operator", + ), +] + +# Property [Operator Slot Duplicate]: a spec containing more than one search +# operator is rejected. +SEARCH_OPERATOR_DUPLICATE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "operator_duplicate_two", + pipeline=[ + { + "$search": { + "text": {"query": "quick", "path": "title"}, + "exists": {"path": "title"}, + } + }, + ], + error_code=UNKNOWN_ERROR, + msg="$search should reject a spec containing two search operators", + ), +] + +# Property [Operator Value Type]: a recognized operator whose value is not a +# document is rejected (a null value is owned by the null-as-missing property +# above). +SEARCH_OPERATOR_VALUE_TYPE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"operator_value_{tid}", + pipeline=[{"$search": {"text": val}}], + error_code=UNKNOWN_ERROR, + msg=f"$search should reject a {tid} operator value as a non-document", + ) + for tid, val in [ + ("string", "quick"), + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("bool", True), + ("array", ["quick"]), + ("objectid", ObjectId("0123456789abcdef01234567")), + ("datetime", datetime.datetime(2020, 1, 1)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ] +] + +# Property [Operator Name Exact Match]: operator names are matched exactly +# (case-sensitive and not whitespace-trimmed). +SEARCH_OPERATOR_NAME_CASE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"operator_name_{tid}", + pipeline=[ + {"$search": {name: {"query": "quick", "path": "title"}}}, + ], + error_code=UNKNOWN_ERROR, + msg=f"$search should reject the {tid} operator name as an unrecognized operator", + ) + for tid, name in [ + ("capitalized", "Text"), + ("trailing_space", "text "), + ("leading_space", " text"), + ] +] + +SEARCH_SPEC_ERROR_TESTS = ( + SEARCH_NULL_MISSING_ERROR_TESTS + + SEARCH_OPERATOR_MISSING_ERROR_TESTS + + SEARCH_OPERATOR_DUPLICATE_ERROR_TESTS + + SEARCH_OPERATOR_VALUE_TYPE_ERROR_TESTS + + SEARCH_OPERATOR_NAME_CASE_ERROR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCH_SPEC_ERROR_TESTS)) +def test_search_spec_errors(indexed_collection, test_case: StageTestCase): + """Test $search rejects null-as-missing sub-fields and operator structural errors.""" + result = execute_command( + indexed_collection, + {"aggregate": indexed_collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult(result, error_code=test_case.error_code, msg=test_case.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_stage_basics.py b/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_stage_basics.py new file mode 100644 index 000000000..b56bd32b1 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_stage_basics.py @@ -0,0 +1,170 @@ +"""Tests for $search stage value typing and silent-empty behavior.""" + +from __future__ import annotations + +import datetime + +import pytest +from bson import Binary, Code, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.stages.search.utils.search_common import ( + FIXTURE_DOCS, + create_dynamic_search_index, +) +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + EXPRESSION_NOT_OBJECT_ERROR, + FAILED_TO_PARSE_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import ( + Len, +) +from documentdb_tests.framework.test_constants import ( + DECIMAL128_ZERO, +) + +pytestmark = pytest.mark.requires(search=True) + + +# Property [Stage Value Array Type]: a $search stage value that is an array is +# rejected with the array-specific parse error, a distinct code path from the +# non-object scalar value. +SEARCH_STAGE_VALUE_ARRAY_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "stage_value_empty_array", + pipeline=[{"$search": []}], + error_code=FAILED_TO_PARSE_ERROR, + msg="$search should reject an empty-array stage value with the array-specific parse error", + ), + StageTestCase( + "stage_value_array_of_object", + pipeline=[{"$search": [{"text": {"query": "quick", "path": "title"}}]}], + error_code=FAILED_TO_PARSE_ERROR, + msg="$search should reject an array stage value even when it wraps a valid operator object", + ), + StageTestCase( + "stage_value_array_of_scalar", + pipeline=[{"$search": [1]}], + error_code=FAILED_TO_PARSE_ERROR, + msg="$search should reject an array stage value of a scalar element with the array error", + ), +] + +# Property [Stage Value Scalar Type]: a $search stage value that is any scalar or +# null is rejected as a non-object, and null is not treated as a missing argument. +SEARCH_STAGE_VALUE_SCALAR_ERROR_TESTS: list[StageTestCase] = [ + *[ + StageTestCase( + f"stage_value_{tid}", + pipeline=[{"$search": val}], + error_code=EXPRESSION_NOT_OBJECT_ERROR, + msg=f"$search should reject a {tid} stage value as a non-object", + ) + for tid, val in [ + ("string", "quick"), + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("decimal128", DECIMAL128_ZERO), + ("bool", True), + ("object_id", ObjectId("0123456789abcdef01234567")), + ("datetime", datetime.datetime(2020, 1, 1)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x00")), + ("regex", Regex("a")), + ("code", Code("x")), + ("min_key", MinKey()), + ("max_key", MaxKey()), + ] + ], + StageTestCase( + "stage_value_null", + pipeline=[{"$search": None}], + error_code=EXPRESSION_NOT_OBJECT_ERROR, + msg="$search should reject a null stage value as a non-object, not treat it as missing", + ), +] + +SEARCH_STAGE_VALUE_ERROR_TESTS = ( + SEARCH_STAGE_VALUE_ARRAY_ERROR_TESTS + SEARCH_STAGE_VALUE_SCALAR_ERROR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCH_STAGE_VALUE_ERROR_TESTS)) +def test_search_stage_value_type_errors(indexed_collection, test_case: StageTestCase): + """Test $search rejects a non-object stage value, distinguishing array from scalar/null.""" + result = execute_command( + indexed_collection, + {"aggregate": indexed_collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult(result, error_code=test_case.error_code, msg=test_case.msg) + + +# Property [Silent Empty Result]: a $search against a nonexistent collection or a +# collection with no search index returns no documents and no error. +SEARCH_SILENT_EMPTY_TESTS: list[StageTestCase] = [ + StageTestCase( + "empty_nonexistent_collection", + docs=None, + pipeline=[ + {"$search": {"text": {"query": "quick", "path": "title"}}}, + ], + expected={"cursor.firstBatch": Len(0)}, + msg="$search on a nonexistent collection should return no documents without error", + ), + StageTestCase( + "empty_no_search_index", + docs=FIXTURE_DOCS, + pipeline=[ + {"$search": {"text": {"query": "quick", "path": "title"}}}, + ], + expected={"cursor.firstBatch": Len(0)}, + msg="$search on a collection with no search index should return no documents without error", + ), +] + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCH_SILENT_EMPTY_TESTS)) +def test_search_silent_empty_cases(collection, test_case: StageTestCase): + """Test $search returns a silent empty result for a missing collection or missing index.""" + if test_case.docs: + collection.insert_many(test_case.docs) + result = execute_command( + collection, {"aggregate": collection.name, "pipeline": test_case.pipeline, "cursor": {}} + ) + assertResult( + result, + expected=test_case.expected, + msg=test_case.msg, + raw_res=True, + ) + + +# Property [Empty Indexed Collection]: a $search against a collection that has a +# search index but no documents returns no documents and no error. +@pytest.mark.aggregate +def test_search_empty_indexed_collection(collection): + """Test $search returns a silent empty result on an empty but indexed collection.""" + collection.database.create_collection(collection.name) + create_dynamic_search_index(collection) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [{"$search": {"text": {"query": "quick", "path": "title"}}}], + "cursor": {}, + }, + ) + assertResult( + result, + expected={"cursor.firstBatch": Len(0)}, + msg="$search on an empty indexed collection should return no documents without error", + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_stored_source.py b/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_stored_source.py new file mode 100644 index 000000000..74540ac40 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_stored_source.py @@ -0,0 +1,194 @@ +"""Tests for the $search returnStoredSource option and behavior.""" + +from __future__ import annotations + +import datetime + +import pytest +from bson import Binary, Code, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.stages.search.utils.search_common import ( + create_search_index, +) +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, +) +from documentdb_tests.framework import fixtures +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BSON_FIELD_NOT_BOOL_ERROR, + UNKNOWN_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import ( + Contains, + Len, +) +from documentdb_tests.framework.test_constants import ( + DECIMAL128_ONE_AND_HALF, +) + +pytestmark = pytest.mark.requires(search=True) + + +_STORED_SOURCE_DOCS = [ + {"_id": 1, "title": "the quick brown fox", "body": "lazy dog"}, + {"_id": 2, "title": "slow green turtle", "body": "quick nap"}, + {"_id": 3, "title": "a quick quick rabbit", "body": "fast"}, +] + +_STORED_SOURCE_INDEX_DEFINITION = { + "mappings": {"dynamic": True}, + "storedSource": {"include": ["title"]}, +} + + +@pytest.fixture(scope="module") +def stored_source_collection(engine_client, worker_id): + """A module-scoped collection with a storedSource-configured search index that + stores only the title field, shared read-only across the returnStoredSource + cases so the index is built and polled once.""" + db_name = fixtures.generate_database_name("stages_search_stored_source", worker_id) + fixtures.cleanup_database(engine_client, db_name) + db = engine_client[db_name] + coll = db["stored_source"] + coll.insert_many(_STORED_SOURCE_DOCS) + create_search_index(coll, _STORED_SOURCE_INDEX_DEFINITION) + yield coll + fixtures.cleanup_database(engine_client, db_name) + + +# Property [ReturnStoredSource False Acceptance]: returnStoredSource accepts a +# boolean false with no coercion and the search still returns its matches (true +# is owned by the stored-source return property). +SEARCH_RETURN_STORED_SOURCE_FALSE_TESTS: list[StageTestCase] = [ + StageTestCase( + "return_stored_source_false", + pipeline=[ + {"$search": {"text": {"query": "quick", "path": "title"}, "returnStoredSource": False}}, + ], + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 1), + Contains("_id", 3), + Contains("_id", 4), + ] + }, + msg="$search should accept returnStoredSource false and still return its matches", + ), +] + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCH_RETURN_STORED_SOURCE_FALSE_TESTS)) +def test_search_return_stored_source_false_cases(indexed_collection, test_case: StageTestCase): + """Test $search accepts returnStoredSource false.""" + result = execute_command( + indexed_collection, + {"aggregate": indexed_collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult( + result, + expected=test_case.expected, + msg=test_case.msg, + raw_res=True, + ) + + +# Property [Stored Source Return]: returnStoredSource true against a +# storedSource-configured index returns the stored-source documents, exposing +# only the configured stored fields and omitting the unstored fields. +SEARCH_STORED_SOURCE_TESTS: list[StageTestCase] = [ + StageTestCase( + "return_stored_source_projects_stored_fields", + pipeline=[ + {"$search": {"text": {"query": "quick", "path": "title"}, "returnStoredSource": True}} + ], + expected=[ + {"_id": 1, "title": "the quick brown fox"}, + {"_id": 3, "title": "a quick quick rabbit"}, + ], + msg="$search returnStoredSource true should return the stored-source documents " + "exposing only the configured stored fields", + ), +] + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCH_STORED_SOURCE_TESTS)) +def test_search_return_stored_source(stored_source_collection, test_case: StageTestCase): + """Test $search returnStoredSource true returns the stored-source documents.""" + result = execute_command( + stored_source_collection, + {"aggregate": stored_source_collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult( + result, + expected=test_case.expected, + msg=test_case.msg, + ignore_doc_order=True, + ) + + +# Property [ReturnStoredSource Without Configured Source]: returnStoredSource true +# against an index that does not configure storedSource is rejected. +SEARCH_RETURN_STORED_SOURCE_CONFIG_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "return_stored_source_unconfigured", + pipeline=[ + {"$search": {"text": {"query": "quick", "path": "title"}, "returnStoredSource": True}}, + ], + error_code=UNKNOWN_ERROR, + msg="$search should reject returnStoredSource true against an index with no " + "storedSource configured", + ), +] + +# Property [ReturnStoredSource Boolean Type]: the returnStoredSource option is +# strictly boolean with no coercion, and a null is not treated as a missing value. +SEARCH_RETURN_STORED_SOURCE_BOOL_TYPE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"return_stored_source_bool_type_{tid}", + pipeline=[ + {"$search": {"text": {"query": "quick", "path": "title"}, "returnStoredSource": val}}, + ], + error_code=BSON_FIELD_NOT_BOOL_ERROR, + msg=f"$search should reject a {tid} returnStoredSource as a non-boolean", + ) + for tid, val in [ + ("string", "true"), + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("object", {"a": 1}), + ("array", [True]), + ("objectid", ObjectId("0123456789abcdef01234567")), + ("datetime", datetime.datetime(2020, 1, 1)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("null", None), + ] +] + +SEARCH_STORED_SOURCE_ERROR_TESTS = ( + SEARCH_RETURN_STORED_SOURCE_CONFIG_ERROR_TESTS + + SEARCH_RETURN_STORED_SOURCE_BOOL_TYPE_ERROR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCH_STORED_SOURCE_ERROR_TESTS)) +def test_search_stored_source_errors(indexed_collection, test_case: StageTestCase): + """Test $search returnStoredSource config and bool-type validation errors.""" + result = execute_command( + indexed_collection, + {"aggregate": indexed_collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult(result, error_code=test_case.error_code, msg=test_case.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_text_analysis.py b/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_text_analysis.py new file mode 100644 index 000000000..14ffcd3cb --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_text_analysis.py @@ -0,0 +1,394 @@ +"""Tests for $search default analyzer tokenization and case folding.""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, +) +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.property_checks import ( + Contains, + Len, +) + +pytestmark = pytest.mark.requires(search=True) + + +# Property [ASCII Case Folding]: ASCII letter case is folded during analysis, so a +# query token matches a stored token of any letter case. +SEARCH_ASCII_CASE_FOLD_TESTS: list[StageTestCase] = [ + StageTestCase( + "case_fold_upper", + pipeline=[ + {"$search": {"text": {"query": "QUICK", "path": "title"}}}, + ], + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 1), + Contains("_id", 3), + Contains("_id", 4), + ] + }, + msg="$search should match an all-uppercase query against a stored lowercase token", + ), + StageTestCase( + "case_fold_mixed", + pipeline=[ + {"$search": {"text": {"query": "QuIcK", "path": "title"}}}, + ], + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 1), + Contains("_id", 3), + Contains("_id", 4), + ] + }, + msg="$search should match a mixed-case query against a stored lowercase token", + ), +] + +# Property [Single-Character Token]: the analyzer preserves a single-character +# token so a one-character query matches its stored one-character form. +SEARCH_SINGLE_CHAR_TOKEN_TESTS: list[StageTestCase] = [ + StageTestCase( + "single_char_token", + pipeline=[{"$search": {"text": {"query": "x", "path": "title"}}}], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 6)]}, + msg="$search should match a single-character query against a stored single-character token", + ), +] + +# Property [Non-ASCII Case Folding]: simple 1:1 case folding is applied to +# non-ASCII cased scripts, so a query matches a stored token of the opposite case. +SEARCH_NON_ASCII_CASE_FOLD_TESTS: list[StageTestCase] = [ + StageTestCase( + "fold_greek", + pipeline=[ + {"$search": {"text": {"query": "ΣΙΓΜΑ", "path": "title"}}}, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 7)]}, + msg="$search should fold uppercase Greek to match a stored lowercase Greek token", + ), + StageTestCase( + "fold_cyrillic", + pipeline=[ + {"$search": {"text": {"query": "ДЕНЬ", "path": "title"}}}, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 8)]}, + msg="$search should fold uppercase Cyrillic to match a stored lowercase Cyrillic token", + ), + StageTestCase( + "fold_supplementary_plane", + # Deseret capital long I (U+10400) folds to small letter long I (U+10428). + pipeline=[ + {"$search": {"text": {"query": "\U00010400", "path": "title"}}}, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 9)]}, + msg="$search should fold an uppercase supplementary-plane letter to match its " + "stored lowercase form", + ), +] + +# Property [No-Match Tokenization]: a query that produces no token matching a +# stored token returns no documents without error. +SEARCH_NO_MATCH_TOKEN_TESTS: list[StageTestCase] = [ + StageTestCase( + "no_match_whitespace", + pipeline=[ + {"$search": {"text": {"query": " ", "path": "title"}}}, + ], + expected={"cursor.firstBatch": Len(0)}, + msg="$search should match nothing for a whitespace-only query", + ), + StageTestCase( + "no_match_punctuation", + pipeline=[ + {"$search": {"text": {"query": "!!!", "path": "title"}}}, + ], + expected={"cursor.firstBatch": Len(0)}, + msg="$search should match nothing for a punctuation-only query", + ), + StageTestCase( + "no_match_null_byte", + pipeline=[ + {"$search": {"text": {"query": "\x00", "path": "title"}}}, + ], + expected={"cursor.firstBatch": Len(0)}, + msg="$search should match nothing for a lone null-byte query", + ), + StageTestCase( + "no_match_cjk", + pipeline=[ + {"$search": {"text": {"query": "日本語", "path": "title"}}}, + ], + expected={"cursor.firstBatch": Len(0)}, + msg="$search should match nothing for a CJK run with no stored token", + ), + StageTestCase( + "no_match_emoji", + pipeline=[ + {"$search": {"text": {"query": "😀😀", "path": "title"}}}, + ], + expected={"cursor.firstBatch": Len(0)}, + msg="$search should match nothing for an emoji run with no stored token", + ), + StageTestCase( + "no_match_accented", + pipeline=[ + {"$search": {"text": {"query": "çàü", "path": "title"}}}, + ], + expected={"cursor.firstBatch": Len(0)}, + msg="$search should match nothing for an accented run with no stored token", + ), + StageTestCase( + "no_match_zwsp", + # Zero-width space (U+200B) carries no token content. + pipeline=[ + {"$search": {"text": {"query": "\u200b", "path": "title"}}}, + ], + expected={"cursor.firstBatch": Len(0)}, + msg="$search should match nothing for a zero-width-space-only query", + ), + StageTestCase( + "no_match_digits", + pipeline=[ + {"$search": {"text": {"query": "12345", "path": "title"}}}, + ], + expected={"cursor.firstBatch": Len(0)}, + msg="$search should match nothing for a pure-digit query with no stored digit token", + ), +] + +# Property [No Diacritic/NFC/NFD/Ligature/Locale Normalization]: the default +# analyzer leaves canonically- or compatibility-equivalent forms as distinct +# tokens, so a query matches only its own stored form and never an equivalent one. +SEARCH_NO_NORMALIZATION_TESTS: list[StageTestCase] = [ + StageTestCase( + "norm_diacritic_plain", + pipeline=[ + {"$search": {"text": {"query": "resume", "path": "title"}}}, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 10)]}, + msg="$search should match a plain-ASCII query only against its undecorated stored token", + ), + StageTestCase( + "norm_diacritic_accented", + pipeline=[ + {"$search": {"text": {"query": "r\u00e9sum\u00e9", "path": "title"}}}, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 5)]}, + msg="$search should match an accented query only against its accented stored token", + ), + StageTestCase( + "norm_nfc_precomposed", + pipeline=[ + {"$search": {"text": {"query": "caf\u00e9", "path": "title"}}}, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 11)]}, + msg="$search should match a precomposed query against its precomposed stored token", + ), + StageTestCase( + "norm_nfd_combining", + # "cafe" followed by combining acute accent (U+0301). + pipeline=[ + {"$search": {"text": {"query": "cafe\u0301", "path": "title"}}}, + ], + expected={"cursor.firstBatch": Len(0)}, + msg="$search should not normalize a combining-form query to match a precomposed token", + ), + StageTestCase( + "norm_ligature_stored", + pipeline=[ + {"$search": {"text": {"query": "\ufb01le", "path": "title"}}}, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 12)]}, + msg="$search should match a ligature query against its ligature stored token", + ), + StageTestCase( + "norm_ligature_decomposed", + pipeline=[ + {"$search": {"text": {"query": "file", "path": "title"}}}, + ], + expected={"cursor.firstBatch": Len(0)}, + msg="$search should not decompose a ligature token to match a plain-letter query", + ), + StageTestCase( + "norm_german_stored", + pipeline=[ + {"$search": {"text": {"query": "stra\u00dfe", "path": "title"}}}, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 13)]}, + msg="$search should match an eszett query against its eszett stored token", + ), + StageTestCase( + "norm_german_lower_expansion", + pipeline=[ + {"$search": {"text": {"query": "strasse", "path": "title"}}}, + ], + expected={"cursor.firstBatch": Len(0)}, + msg="$search should not expand eszett to ss to match a lowercase query", + ), + StageTestCase( + "norm_german_upper_expansion", + pipeline=[ + {"$search": {"text": {"query": "STRASSE", "path": "title"}}}, + ], + expected={"cursor.firstBatch": Len(0)}, + msg="$search should not expand eszett to ss to match an uppercase query", + ), + StageTestCase( + "norm_turkish_stored", + pipeline=[ + {"$search": {"text": {"query": "\u0131rmak", "path": "title"}}}, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 14)]}, + msg="$search should match a dotless-i query against its dotless-i stored token", + ), + StageTestCase( + "norm_turkish_upper", + pipeline=[ + {"$search": {"text": {"query": "IRMAK", "path": "title"}}}, + ], + expected={"cursor.firstBatch": Len(0)}, + msg="$search should not locale-fold an uppercase I to match a dotless-i token", + ), + StageTestCase( + "norm_turkish_ascii_lower", + pipeline=[ + {"$search": {"text": {"query": "irmak", "path": "title"}}}, + ], + expected={"cursor.firstBatch": Len(0)}, + msg="$search should treat ASCII dotted i as distinct from a dotless-i token", + ), +] + +# Property [ASCII Fold Range Edges]: ASCII case folding applies precisely at the +# A-Z range boundaries, with no off-by-one at the first or last letter of the range. +SEARCH_ASCII_EDGE_FOLD_TESTS: list[StageTestCase] = [ + StageTestCase( + "edge_fold_first_letter", + pipeline=[{"$search": {"text": {"query": "A", "path": "title"}}}], + expected={"cursor.firstBatch": [Len(2), Contains("_id", 3), Contains("_id", 15)]}, + msg="$search should fold an uppercase A at the range start to match a stored lowercase a", + ), + StageTestCase( + "edge_fold_last_letter", + pipeline=[{"$search": {"text": {"query": "Z", "path": "title"}}}], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 15)]}, + msg="$search should fold an uppercase Z at the range end to match a stored lowercase z", + ), +] + +# Property [Whitespace and Control Token Boundaries]: Unicode whitespace +# categories, control characters, and backslash each act as a token boundary +# identically to ASCII space, splitting a query into separate tokens, while a +# backslash-only query yields no token and matches nothing. +SEARCH_TOKEN_BOUNDARY_TESTS: list[StageTestCase] = [ + *[ + StageTestCase( + f"boundary_{name}", + pipeline=[ + {"$search": {"text": {"query": f"word{sep}joined", "path": "title"}}}, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 16)]}, + msg=f"$search should treat {desc} as a token boundary, matching the " + "two-token document and not the one-token document", + ) + for name, sep, desc in [ + ("nbsp", "\u00a0", "a no-break space (U+00A0)"), + ("en_space", "\u2000", "an en space (U+2000)"), + ("tab", "\t", "a tab"), + ("newline", "\n", "a newline"), + ("control_0001", "\u0001", "a control character (U+0001)"), + ("control_001f", "\u001f", "a control character (U+001F)"), + ("backslash", "\\", "a backslash"), + ] + ], + StageTestCase( + "boundary_backslash_only", + pipeline=[ + {"$search": {"text": {"query": "\\", "path": "title"}}}, + ], + expected={"cursor.firstBatch": Len(0)}, + msg="$search should produce no token for a backslash-only query and match nothing", + ), +] + +# Property [Zero-Width Token Boundaries]: a zero-width space splits a token while +# an embedded BOM or ZWJ is retained inside the token (matching neither the split +# nor the joined form), and a leading BOM is stripped so the remaining token still +# matches. +SEARCH_ZERO_WIDTH_BOUNDARY_TESTS: list[StageTestCase] = [ + StageTestCase( + "zwsp_boundary", + # Zero-width space (U+200B) acts as a token boundary. + pipeline=[ + {"$search": {"text": {"query": "word\u200bjoined", "path": "title"}}}, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 16)]}, + msg="$search should treat a zero-width space as a token boundary, matching the " + "two-token document and not the one-token document", + ), + StageTestCase( + "bom_embedded_retained", + # BOM (U+FEFF) embedded mid-token is retained inside the token. + pipeline=[ + {"$search": {"text": {"query": "word\ufeffjoined", "path": "title"}}}, + ], + expected={"cursor.firstBatch": Len(0)}, + msg="$search should retain an embedded BOM inside the token, matching neither the " + "split nor the joined form", + ), + StageTestCase( + "zwj_embedded_retained", + # ZWJ (U+200D) embedded mid-token is retained inside the token. + pipeline=[ + {"$search": {"text": {"query": "word\u200djoined", "path": "title"}}}, + ], + expected={"cursor.firstBatch": Len(0)}, + msg="$search should retain an embedded ZWJ inside the token, matching neither the " + "split nor the joined form", + ), + StageTestCase( + "bom_leading_stripped", + # A leading BOM (U+FEFF) is stripped, leaving the joined token intact. + pipeline=[ + {"$search": {"text": {"query": "\ufeffwordjoined", "path": "title"}}}, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 17)]}, + msg="$search should strip a leading BOM so the remaining one-token form still matches", + ), +] + +SEARCH_TEXT_ANALYSIS_TESTS = ( + SEARCH_ASCII_CASE_FOLD_TESTS + + SEARCH_SINGLE_CHAR_TOKEN_TESTS + + SEARCH_NON_ASCII_CASE_FOLD_TESTS + + SEARCH_NO_MATCH_TOKEN_TESTS + + SEARCH_NO_NORMALIZATION_TESTS + + SEARCH_ASCII_EDGE_FOLD_TESTS + + SEARCH_TOKEN_BOUNDARY_TESTS + + SEARCH_ZERO_WIDTH_BOUNDARY_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCH_TEXT_ANALYSIS_TESTS)) +def test_search_text_analysis_cases(indexed_collection, test_case: StageTestCase): + """Test $search default analyzer tokenization and case folding.""" + result = execute_command( + indexed_collection, + {"aggregate": indexed_collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult( + result, + expected=test_case.expected, + msg=test_case.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_text_basics.py b/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_text_basics.py new file mode 100644 index 000000000..7e458bf87 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_text_basics.py @@ -0,0 +1,231 @@ +"""Tests for $search text operator core matching behavior.""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, +) +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.property_checks import ( + Contains, + Len, +) + +pytestmark = pytest.mark.requires(search=True) + + +# Property [Index-Covered Matching]: a text operator returns exactly the +# documents whose covered path contains the query token, and a path no document +# covers matches nothing. +SEARCH_MATCHING_TESTS: list[StageTestCase] = [ + StageTestCase( + "matching_covered_title", + pipeline=[ + {"$search": {"text": {"query": "quick", "path": "title"}}}, + ], + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 1), + Contains("_id", 3), + Contains("_id", 4), + ] + }, + msg="$search should return the documents whose covered path contains the query token", + ), + StageTestCase( + "matching_covered_body", + pipeline=[ + {"$search": {"text": {"query": "quick", "path": "body"}}}, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 2)]}, + msg="$search should match only on the specific covered path named in the operator", + ), + StageTestCase( + "matching_uncovered_path", + pipeline=[ + {"$search": {"text": {"query": "quick", "path": "nope"}}}, + ], + expected={"cursor.firstBatch": Len(0)}, + msg="$search should return no documents for a path no document covers", + ), +] + +# Property [Literal Spec Values]: $search spec values are interpreted as literal +# data, never as field paths, system variables, or expressions. +SEARCH_LITERAL_SPEC_TESTS: list[StageTestCase] = [ + StageTestCase( + "literal_path_empty", + pipeline=[{"$search": {"text": {"query": "quick", "path": ""}}}], + expected={"cursor.firstBatch": Len(0)}, + msg="$search should treat an empty path as literal data and match nothing without error", + ), + StageTestCase( + "literal_path_field_ref", + pipeline=[ + {"$search": {"text": {"query": "quick", "path": "$title"}}}, + ], + expected={"cursor.firstBatch": Len(0)}, + msg="$search should treat a $-prefixed path as literal data, not a field reference", + ), + StageTestCase( + "literal_path_dotted", + pipeline=[ + {"$search": {"text": {"query": "quick", "path": "a.b"}}}, + ], + expected={"cursor.firstBatch": Len(0)}, + msg="$search should treat a dotted path as literal data with no field-path validation", + ), + StageTestCase( + "literal_path_null_byte", + pipeline=[ + {"$search": {"text": {"query": "quick", "path": "ti\x00tle"}}}, + ], + expected={"cursor.firstBatch": Len(0)}, + msg="$search should treat a null-byte path as literal data and match nothing without error", + ), + StageTestCase( + "literal_query_dollar", + pipeline=[ + {"$search": {"text": {"query": "$quick", "path": "title"}}}, + ], + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 1), + Contains("_id", 3), + Contains("_id", 4), + ] + }, + msg="$search should match a $-prefixed query as literal text, not as a field reference", + ), +] + +# Property [Null Sub-field As Default]: a null document- or string-typed +# sub-field (index, count, highlight, sort, text.fuzzy) is treated as +# missing/default. +SEARCH_NULL_DEFAULT_TESTS: list[StageTestCase] = [ + StageTestCase( + "null_default_index", + pipeline=[ + {"$search": {"text": {"query": "quick", "path": "title"}, "index": None}}, + ], + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 1), + Contains("_id", 3), + Contains("_id", 4), + ] + }, + msg="$search should treat a null index as the default index and still match", + ), + StageTestCase( + "null_default_count", + pipeline=[ + {"$search": {"text": {"query": "quick", "path": "title"}, "count": None}}, + ], + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 1), + Contains("_id", 3), + Contains("_id", 4), + ] + }, + msg="$search should treat a null count as omitted and still match", + ), + StageTestCase( + "null_default_highlight", + pipeline=[ + {"$search": {"text": {"query": "quick", "path": "title"}, "highlight": None}}, + ], + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 1), + Contains("_id", 3), + Contains("_id", 4), + ] + }, + msg="$search should treat a null highlight as omitted and still match", + ), + StageTestCase( + "null_default_sort", + pipeline=[ + {"$search": {"text": {"query": "quick", "path": "title"}, "sort": None}}, + ], + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 1), + Contains("_id", 3), + Contains("_id", 4), + ] + }, + msg="$search should treat a null sort as omitted and still match", + ), + StageTestCase( + "null_default_fuzzy", + pipeline=[ + {"$search": {"text": {"query": "quick", "path": "title", "fuzzy": None}}}, + ], + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 1), + Contains("_id", 3), + Contains("_id", 4), + ] + }, + msg="$search should treat a null text.fuzzy as omitted and still match", + ), +] + +# Property [Multi-Term OR]: a multi-term query array matches documents containing +# any of its terms. +SEARCH_MULTI_TERM_OR_TESTS: list[StageTestCase] = [ + StageTestCase( + "multi_term_or", + pipeline=[ + {"$search": {"text": {"query": ["quick", "turtle"], "path": "title"}}}, + ], + expected={ + "cursor.firstBatch": [ + Len(4), + Contains("_id", 1), + Contains("_id", 2), + Contains("_id", 3), + Contains("_id", 4), + ] + }, + msg="$search should match documents containing any term in a multi-term query array", + ), +] + +SEARCH_TEXT_BASICS_TESTS = ( + SEARCH_MATCHING_TESTS + + SEARCH_LITERAL_SPEC_TESTS + + SEARCH_NULL_DEFAULT_TESTS + + SEARCH_MULTI_TERM_OR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCH_TEXT_BASICS_TESTS)) +def test_search_text_basics_cases(indexed_collection, test_case: StageTestCase): + """Test $search core text matching over an indexed collection.""" + result = execute_command( + indexed_collection, + {"aggregate": indexed_collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult( + result, + expected=test_case.expected, + msg=test_case.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_text_errors.py b/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_text_errors.py new file mode 100644 index 000000000..858834305 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_text_errors.py @@ -0,0 +1,409 @@ +"""Tests for $search text operator and fuzzy validation errors.""" + +from __future__ import annotations + +import datetime + +import pytest +from bson import Binary, Code, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.stages.search.utils.search_common import ( + QUERY_CLAUSE_CAP, +) +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + UNKNOWN_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.lazy_payload import lazy +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_ONE_AND_HALF, + STRING_SIZE_LIMIT_BYTES, +) + +pytestmark = pytest.mark.requires(search=True) + + +# Property [text query Validation]: text.query is required and must be a +# non-empty string or array of non-null strings. +SEARCH_TEXT_QUERY_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "text_query_missing", + pipeline=[{"$search": {"text": {"path": "title"}}}], + error_code=UNKNOWN_ERROR, + msg="$search should reject a text operator missing the required query", + ), + StageTestCase( + "text_query_empty_string", + pipeline=[{"$search": {"text": {"query": "", "path": "title"}}}], + error_code=UNKNOWN_ERROR, + msg="$search should reject an empty-string text.query", + ), + StageTestCase( + "text_query_empty_array", + pipeline=[{"$search": {"text": {"query": [], "path": "title"}}}], + error_code=UNKNOWN_ERROR, + msg="$search should reject an empty-array text.query", + ), + *[ + StageTestCase( + f"text_query_non_string_{tid}", + pipeline=[ + {"$search": {"text": {"query": val, "path": "title"}}}, + ], + error_code=UNKNOWN_ERROR, + msg=f"$search should reject a {tid} text.query as a non-string", + ) + for tid, val in [ + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("bool", True), + ("object", {"q": "quick"}), + ("objectid", ObjectId("0123456789abcdef01234567")), + ("datetime", datetime.datetime(2020, 1, 1)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ] + ], + StageTestCase( + "text_query_array_element_null", + pipeline=[ + {"$search": {"text": {"query": ["quick", None], "path": "title"}}}, + ], + error_code=UNKNOWN_ERROR, + msg="$search should reject a null query-array element", + ), + StageTestCase( + "text_query_array_element_non_string", + pipeline=[ + {"$search": {"text": {"query": ["quick", 1], "path": "title"}}}, + ], + error_code=UNKNOWN_ERROR, + msg="$search should reject a non-string query-array element", + ), +] + +# Property [text path Validation]: text.path is required and must be a string, +# document, or non-empty array, and a path document must carry a value and a +# configured multi-analyzer. +SEARCH_TEXT_PATH_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "text_path_missing", + pipeline=[{"$search": {"text": {"query": "quick"}}}], + error_code=UNKNOWN_ERROR, + msg="$search should reject a text operator missing the required path", + ), + StageTestCase( + "text_path_empty_array", + pipeline=[{"$search": {"text": {"query": "quick", "path": []}}}], + error_code=UNKNOWN_ERROR, + msg="$search should reject an empty-array text.path", + ), + *[ + StageTestCase( + f"text_path_non_string_non_document_{tid}", + pipeline=[ + {"$search": {"text": {"query": "quick", "path": val}}}, + ], + error_code=UNKNOWN_ERROR, + msg=f"$search should reject a {tid} text.path as neither a string nor a document", + ) + for tid, val in [ + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("bool", True), + ("objectid", ObjectId("0123456789abcdef01234567")), + ("datetime", datetime.datetime(2020, 1, 1)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ] + ], + StageTestCase( + "text_path_object_no_value", + pipeline=[{"$search": {"text": {"query": "quick", "path": {}}}}], + error_code=UNKNOWN_ERROR, + msg="$search should reject a text.path document with no value field", + ), + StageTestCase( + "text_path_object_absent_multi", + pipeline=[ + {"$search": {"text": {"query": "quick", "path": {"value": "title", "multi": "nope"}}}}, + ], + error_code=UNKNOWN_ERROR, + msg="$search should reject a text.path document referencing an absent " + "multi-analyzer config", + ), +] + +# Property [text matchCriteria Validation]: text.matchCriteria must be the string +# "all" or "any". +SEARCH_TEXT_MATCH_CRITERIA_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "text_match_criteria_bad_enum", + pipeline=[ + {"$search": {"text": {"query": "quick", "path": "title", "matchCriteria": "none"}}}, + ], + error_code=UNKNOWN_ERROR, + msg="$search should reject a matchCriteria outside the set [all, any]", + ), + *[ + StageTestCase( + f"text_match_criteria_type_{tid}", + pipeline=[ + {"$search": {"text": {"query": "quick", "path": "title", "matchCriteria": val}}}, + ], + error_code=UNKNOWN_ERROR, + msg=f"$search should reject a {tid} matchCriteria as a non-string", + ) + for tid, val in [ + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("bool", True), + ("object", {"a": 1}), + ("array", ["all"]), + ("objectid", ObjectId("0123456789abcdef01234567")), + ("datetime", datetime.datetime(2020, 1, 1)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ] + ], +] + +# Property [text synonyms Validation]: text.synonyms must name a configured +# synonym mapping. +SEARCH_TEXT_SYNONYMS_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "text_synonyms_unknown_name", + pipeline=[ + {"$search": {"text": {"query": "quick", "path": "title", "synonyms": "nope"}}}, + ], + error_code=UNKNOWN_ERROR, + msg="$search should reject a text.synonyms referencing an unknown synonym mapping name", + ), +] + +# Property [text fuzzy Validation]: text.fuzzy must be a document and rejects an +# unknown sub-field (a null fuzzy is treated as the default). +SEARCH_TEXT_FUZZY_ERROR_TESTS: list[StageTestCase] = [ + *[ + StageTestCase( + f"text_fuzzy_type_{tid}", + pipeline=[ + {"$search": {"text": {"query": "quick", "path": "title", "fuzzy": val}}}, + ], + error_code=UNKNOWN_ERROR, + msg=f"$search should reject a {tid} text.fuzzy as a non-document", + ) + for tid, val in [ + ("string", "x"), + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("bool", True), + ("array", [{}]), + ("objectid", ObjectId("0123456789abcdef01234567")), + ("datetime", datetime.datetime(2020, 1, 1)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ] + ], + StageTestCase( + "text_fuzzy_unknown_subfield", + pipeline=[ + {"$search": {"text": {"query": "quick", "path": "title", "fuzzy": {"bogus": 1}}}}, + ], + error_code=UNKNOWN_ERROR, + msg="$search should reject an unknown text.fuzzy sub-field", + ), +] + +# Property [text score Validation]: text.score must be a document (a null score +# is treated as the default). +SEARCH_TEXT_SCORE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"text_score_type_{tid}", + pipeline=[ + {"$search": {"text": {"query": "quick", "path": "title", "score": val}}}, + ], + error_code=UNKNOWN_ERROR, + msg=f"$search should reject a {tid} text.score as a non-document", + ) + for tid, val in [ + ("string", "x"), + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("bool", True), + ("array", [{}]), + ("objectid", ObjectId("0123456789abcdef01234567")), + ("datetime", datetime.datetime(2020, 1, 1)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ] +] + +# Property [Query/Token Size]: a query that resolves to more than the clause cap is +# rejected, whether from a long query array or from a single string the analyzer +# splits into many sub-tokens, so the cap is clause-count based, not a byte-size limit. +SEARCH_QUERY_TOKEN_SIZE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "query_array_over_clause_cap", + pipeline=[ + { + "$search": { + "text": { + "query": ["quick"] + [f"nomatch{i}" for i in range(QUERY_CLAUSE_CAP)], + "path": "title", + } + } + }, + ], + error_code=UNKNOWN_ERROR, + msg="$search should reject a query array one past the inclusive clause cap", + ), + # The run spans the BSON string size limit; it is still rejected with the + # clause-count error rather than a byte-size error. + StageTestCase( + "query_single_byte_run_over_cap", + pipeline=[ + { + "$search": { + "text": { + "query": lazy(lambda: "a" * STRING_SIZE_LIMIT_BYTES), + "path": "title", + } + } + }, + ], + error_code=UNKNOWN_ERROR, + msg="$search should reject a multi-megabyte single-character run the analyzer " + "splits into more sub-tokens than the clause cap", + ), + StageTestCase( + "query_multi_byte_run_over_cap", + pipeline=[ + { + "$search": { + "text": { + "query": lazy(lambda: "\u00e9" * (STRING_SIZE_LIMIT_BYTES // 2)), + "path": "title", + } + } + }, + ], + error_code=UNKNOWN_ERROR, + msg="$search should reject a multi-byte run at the same byte size, showing the " + "cap is clause-count based and not byte based", + ), +] + +# Property [Fuzzy maxEdits Enum]: text.fuzzy.maxEdits accepts only 1 or 2, so any +# other integer value is rejected. +SEARCH_FUZZY_MAX_EDITS_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"fuzzy_max_edits_{tid}", + pipeline=[ + {"$search": {"text": {"query": "quick", "path": "title", "fuzzy": {"maxEdits": val}}}}, + ], + error_code=UNKNOWN_ERROR, + msg=f"$search should reject a fuzzy.maxEdits of {tid} outside the set 1 or 2", + ) + for tid, val in [ + ("zero", 0), + ("three", 3), + ] +] + +# Property [Fuzzy prefixLength Lower Bound]: a negative text.fuzzy.prefixLength is +# rejected. +SEARCH_FUZZY_PREFIX_LENGTH_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "fuzzy_prefix_length_negative", + pipeline=[ + { + "$search": { + "text": {"query": "quick", "path": "title", "fuzzy": {"prefixLength": -1}} + } + }, + ], + error_code=UNKNOWN_ERROR, + msg="$search should reject a negative fuzzy.prefixLength", + ), +] + +# Property [Fuzzy maxExpansions Bounds]: text.fuzzy.maxExpansions must fall within +# 1..1000, so a value outside those bounds is rejected. +SEARCH_FUZZY_MAX_EXPANSIONS_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"fuzzy_max_expansions_{tid}", + pipeline=[ + { + "$search": { + "text": {"query": "quick", "path": "title", "fuzzy": {"maxExpansions": val}} + } + }, + ], + error_code=UNKNOWN_ERROR, + msg=f"$search should reject a fuzzy.maxExpansions of {tid} outside the bounds 1 to 1000", + ) + for tid, val in [ + ("zero", 0), + ("over_max", 1001), + ] +] + +SEARCH_TEXT_ERROR_TESTS = ( + SEARCH_TEXT_QUERY_ERROR_TESTS + + SEARCH_TEXT_PATH_ERROR_TESTS + + SEARCH_TEXT_MATCH_CRITERIA_ERROR_TESTS + + SEARCH_TEXT_SYNONYMS_ERROR_TESTS + + SEARCH_TEXT_FUZZY_ERROR_TESTS + + SEARCH_TEXT_SCORE_ERROR_TESTS + + SEARCH_QUERY_TOKEN_SIZE_ERROR_TESTS + + SEARCH_FUZZY_MAX_EDITS_ERROR_TESTS + + SEARCH_FUZZY_PREFIX_LENGTH_ERROR_TESTS + + SEARCH_FUZZY_MAX_EXPANSIONS_ERROR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCH_TEXT_ERROR_TESTS)) +def test_search_text_errors(indexed_collection, test_case: StageTestCase): + """Test $search text operator and fuzzy validation errors.""" + result = execute_command( + indexed_collection, + {"aggregate": indexed_collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult(result, error_code=test_case.error_code, msg=test_case.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_text_operator.py b/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_text_operator.py new file mode 100644 index 000000000..3b20de159 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_text_operator.py @@ -0,0 +1,420 @@ +"""Tests for $search text operator options (path forms, matchCriteria, score, fuzzy).""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.stages.search.utils.search_common import ( + QUERY_CLAUSE_CAP, + create_search_index, +) +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, +) +from documentdb_tests.framework import fixtures +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.property_checks import ( + Contains, + Len, +) + +pytestmark = pytest.mark.requires(search=True) + + +# Property [text Path Forms]: the text operator accepts a {value} document, a +# {wildcard} document, and an array of paths, each resolving to the covered +# field(s) it names. +SEARCH_TEXT_PATH_FORMS_TESTS: list[StageTestCase] = [ + StageTestCase( + "path_value_document", + pipeline=[ + {"$search": {"text": {"query": "quick", "path": {"value": "title"}}}}, + ], + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 1), + Contains("_id", 3), + Contains("_id", 4), + ] + }, + msg="$search should accept a {value} path document resolving to the named field", + ), + StageTestCase( + "path_wildcard_document", + pipeline=[ + {"$search": {"text": {"query": "quick", "path": {"wildcard": "*"}}}}, + ], + expected={ + "cursor.firstBatch": [ + Len(4), + Contains("_id", 1), + Contains("_id", 2), + Contains("_id", 3), + Contains("_id", 4), + ] + }, + msg="$search should accept a {wildcard} path document spanning every covered field", + ), + StageTestCase( + "path_array", + pipeline=[ + {"$search": {"text": {"query": "quick", "path": ["title", "body"]}}}, + ], + expected={ + "cursor.firstBatch": [ + Len(4), + Contains("_id", 1), + Contains("_id", 2), + Contains("_id", 3), + Contains("_id", 4), + ] + }, + msg="$search should accept an array of paths and match across all of them", + ), +] + +# Property [text matchCriteria]: matchCriteria "all" requires every query term to +# be present (AND) while "any" requires only one (OR). +SEARCH_TEXT_MATCH_CRITERIA_TESTS: list[StageTestCase] = [ + StageTestCase( + "match_criteria_all", + pipeline=[ + { + "$search": { + "text": {"query": "quick brown", "path": "title", "matchCriteria": "all"} + } + }, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 1)]}, + msg="$search should match only documents containing every term when matchCriteria is all", + ), + StageTestCase( + "match_criteria_any", + pipeline=[ + { + "$search": { + "text": {"query": "quick brown", "path": "title", "matchCriteria": "any"} + } + }, + ], + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 1), + Contains("_id", 3), + Contains("_id", 4), + ] + }, + msg="$search should match documents containing any term when matchCriteria is any", + ), +] + +# Property [text score Document]: the text operator accepts a score document and +# still returns the matched documents. +SEARCH_TEXT_SCORE_TESTS: list[StageTestCase] = [ + StageTestCase( + "score_document", + pipeline=[ + { + "$search": { + "text": { + "query": "quick", + "path": "title", + "score": {"boost": {"value": 2.0}}, + } + } + }, + ], + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 1), + Contains("_id", 3), + Contains("_id", 4), + ] + }, + msg="$search should accept a text.score document and still return the matches", + ), +] + +# Property [text query Array Cap]: a query array of the maximum 1024 elements +# (inclusive) is accepted and matches as a multi-term OR. +SEARCH_TEXT_QUERY_ARRAY_CAP_TESTS: list[StageTestCase] = [ + StageTestCase( + "query_array_max_clauses", + pipeline=[ + { + "$search": { + "text": { + "query": ["quick"] + [f"nomatch{i}" for i in range(QUERY_CLAUSE_CAP - 1)], + "path": "title", + } + } + }, + ], + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 1), + Contains("_id", 3), + Contains("_id", 4), + ] + }, + msg="$search should accept a query array sized at the inclusive clause cap", + ), +] + +# Property [text Fuzzy Matching]: the text operator accepts a fuzzy document and +# matches within a code-point-based edit distance applied independently per +# query-array element. +SEARCH_TEXT_FUZZY_TESTS: list[StageTestCase] = [ + StageTestCase( + "fuzzy_empty_document", + pipeline=[ + {"$search": {"text": {"query": "quick", "path": "title", "fuzzy": {}}}}, + ], + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 1), + Contains("_id", 3), + Contains("_id", 4), + ] + }, + msg="$search should accept an empty fuzzy document and still match the exact term", + ), + StageTestCase( + "fuzzy_max_edits_1_codepoint", + # cafe -> café is one code-point edit (é ↔ e), not two bytes. + pipeline=[ + {"$search": {"text": {"query": "cafe", "path": "title", "fuzzy": {"maxEdits": 1}}}}, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 11)]}, + msg="$search should treat an accent difference as one code-point edit at maxEdits 1", + ), + StageTestCase( + "fuzzy_max_edits_2_codepoint", + # resume -> résumé is two code-point edits. + pipeline=[ + {"$search": {"text": {"query": "resume", "path": "title", "fuzzy": {"maxEdits": 2}}}}, + ], + expected={"cursor.firstBatch": [Len(2), Contains("_id", 5), Contains("_id", 10)]}, + msg="$search should match within two code-point edits at maxEdits 2", + ), + StageTestCase( + "fuzzy_max_expansions_min", + pipeline=[ + { + "$search": { + "text": { + "query": "cafe", + "path": "title", + "fuzzy": {"maxEdits": 1, "maxExpansions": 1}, + } + } + }, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 11)]}, + msg="$search should accept fuzzy.maxExpansions at the lower bound and still match", + ), + StageTestCase( + "fuzzy_max_expansions_max", + pipeline=[ + { + "$search": { + "text": { + "query": "cafe", + "path": "title", + "fuzzy": {"maxEdits": 1, "maxExpansions": 1000}, + } + } + }, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 11)]}, + msg="$search should accept fuzzy.maxExpansions at the upper bound and still match", + ), + StageTestCase( + "fuzzy_per_element_array", + pipeline=[ + { + "$search": { + "text": { + "query": ["quik", "turtl"], + "path": "title", + "fuzzy": {"maxEdits": 1}, + } + } + }, + ], + expected={ + "cursor.firstBatch": [ + Len(4), + Contains("_id", 1), + Contains("_id", 2), + Contains("_id", 3), + Contains("_id", 4), + ] + }, + msg="$search should apply fuzzy matching independently to each query-array element", + ), +] + +# Property [text Fuzzy prefixLength]: fuzzy.prefixLength locks a code-point-counted +# prefix from edits, so a typo within that prefix does not match. +SEARCH_TEXT_FUZZY_PREFIX_TESTS: list[StageTestCase] = [ + StageTestCase( + "fuzzy_prefix_unlocked", + # éfoy -> éfox is one edit at code point 3; prefixLength 0 locks nothing. + pipeline=[ + { + "$search": { + "text": { + "query": "\u00e9foy", + "path": "title", + "fuzzy": {"maxEdits": 1, "prefixLength": 0}, + } + } + }, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 18)]}, + msg="$search should allow a fuzzy edit outside the prefix when prefixLength is 0", + ), + StageTestCase( + "fuzzy_prefix_locked_codepoint", + # prefixLength 4 locks all four code points of éfox (5 bytes), so the typo + # at code point 3 falls inside the locked prefix; byte counting would lock + # only éfo (3 code points) and still allow the edit. + pipeline=[ + { + "$search": { + "text": { + "query": "\u00e9foy", + "path": "title", + "fuzzy": {"maxEdits": 1, "prefixLength": 4}, + } + } + }, + ], + expected={"cursor.firstBatch": Len(0)}, + msg="$search should count prefixLength in code points so a locked-prefix typo does " + "not match", + ), +] + +SEARCH_TEXT_OPERATOR_TESTS = ( + SEARCH_TEXT_PATH_FORMS_TESTS + + SEARCH_TEXT_MATCH_CRITERIA_TESTS + + SEARCH_TEXT_SCORE_TESTS + + SEARCH_TEXT_QUERY_ARRAY_CAP_TESTS + + SEARCH_TEXT_FUZZY_TESTS + + SEARCH_TEXT_FUZZY_PREFIX_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCH_TEXT_OPERATOR_TESTS)) +def test_search_text_operator_cases(indexed_collection, test_case: StageTestCase): + """Test $search text operator path forms, matchCriteria, score, fuzzy.""" + result = execute_command( + indexed_collection, + {"aggregate": indexed_collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult( + result, + expected=test_case.expected, + msg=test_case.msg, + raw_res=True, + ) + + +_MULTI_ANALYZER_DOCS = [ + {"_id": 1, "title": "the quick brown fox"}, + {"_id": 2, "title": "quick red fox"}, + {"_id": 3, "title": "quick"}, + {"_id": 4, "title": "slow green turtle"}, +] + +_MULTI_ANALYZER_INDEX_DEFINITION = { + "mappings": { + "dynamic": False, + "fields": { + "title": { + "type": "string", + "analyzer": "lucene.standard", + "multi": {"kw": {"type": "string", "analyzer": "lucene.keyword"}}, + } + }, + } +} + + +@pytest.fixture(scope="module") +def multi_analyzer_collection(engine_client, worker_id): + """A module-scoped collection whose title carries a keyword multi-analyzer + beside the default standard analyzer, so a {value, multi} path can select it.""" + db_name = fixtures.generate_database_name("stages_search_multi_analyzer", worker_id) + fixtures.cleanup_database(engine_client, db_name) + db = engine_client[db_name] + coll = db["multi_analyzer"] + coll.insert_many(_MULTI_ANALYZER_DOCS) + create_search_index(coll, _MULTI_ANALYZER_INDEX_DEFINITION) + yield coll + fixtures.cleanup_database(engine_client, db_name) + + +# Property [text Multi-Analyzer Path]: a {value, multi} path resolves through the +# named alternate analyzer, so the same query matches a different document set +# than the field's default analyzer. +SEARCH_TEXT_MULTI_ANALYZER_TESTS: list[StageTestCase] = [ + StageTestCase( + "multi_default_standard_analyzer", + pipeline=[{"$search": {"text": {"query": "quick", "path": "title"}}}], + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 1), + Contains("_id", 2), + Contains("_id", 3), + ] + }, + msg="$search text should match every standard-analyzed title containing the term", + ), + StageTestCase( + "multi_keyword_analyzer_term", + pipeline=[ + {"$search": {"text": {"query": "quick", "path": {"value": "title", "multi": "kw"}}}} + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 3)]}, + msg="$search text should match only the keyword-analyzed title equal to the term when the " + "multi keyword analyzer is selected", + ), + StageTestCase( + "multi_keyword_analyzer_full_value", + pipeline=[ + { + "$search": { + "text": { + "query": "the quick brown fox", + "path": {"value": "title", "multi": "kw"}, + } + } + } + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 1)]}, + msg="$search text should match the keyword-analyzed title equal to the full query value", + ), +] + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCH_TEXT_MULTI_ANALYZER_TESTS)) +def test_search_text_multi_analyzer(multi_analyzer_collection, test_case: StageTestCase): + """Test $search text {value, multi} path selecting an alternate analyzer.""" + result = execute_command( + multi_analyzer_collection, + {"aggregate": multi_analyzer_collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult(result, expected=test_case.expected, msg=test_case.msg, raw_res=True) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_text_query_operators.py b/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_text_query_operators.py new file mode 100644 index 000000000..a1cc2d131 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_text_query_operators.py @@ -0,0 +1,359 @@ +"""Tests for the $search queryString, term, and moreLikeThis operators.""" + +from __future__ import annotations + +import datetime + +import pytest +from bson import Binary, Code, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + UNKNOWN_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import ( + Contains, + Len, +) +from documentdb_tests.framework.test_constants import ( + DECIMAL128_ONE_AND_HALF, +) + +pytestmark = pytest.mark.requires(search=True) + + +# Property [QueryString Lucene Syntax]: the queryString operator parses its query +# as Lucene syntax, so boolean operators and field-scoped terms take effect. +SEARCH_QUERY_STRING_TESTS: list[StageTestCase] = [ + StageTestCase( + "query_string_boolean_and", + pipeline=[ + {"$search": {"queryString": {"query": "quick AND brown", "defaultPath": "title"}}}, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 1)]}, + msg="$search queryString should require every term of a boolean AND query to be present", + ), + StageTestCase( + "query_string_score_boost", + pipeline=[ + { + "$search": { + "queryString": { + "query": "quick AND brown", + "defaultPath": "title", + "score": {"boost": {"value": 2.0}}, + } + } + }, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 1)]}, + msg="$search queryString should accept a score modifier and still return its matches", + ), + StageTestCase( + "query_string_field_scoped", + pipeline=[ + {"$search": {"queryString": {"query": "body:quick", "defaultPath": "title"}}}, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 2)]}, + msg="$search queryString should restrict a field-scoped term to the named field", + ), +] + +# Property [Term Token Match]: the deprecated term operator executes a token +# match against the index, returning the documents whose covered path contains +# the query token. +SEARCH_TERM_TESTS: list[StageTestCase] = [ + StageTestCase( + "term_token_match_title", + pipeline=[ + {"$search": {"term": {"path": "title", "query": "quick"}}}, + ], + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 1), + Contains("_id", 3), + Contains("_id", 4), + ] + }, + msg="$search term should execute a token match, returning the documents whose " + "covered path contains the query token", + ), +] + +# Property [MoreLikeThis Similarity]: moreLikeThis analyzes the example text in +# like:{:} and returns the documents whose covered field shares its +# significant terms. +SEARCH_MORE_LIKE_THIS_TESTS: list[StageTestCase] = [ + StageTestCase( + "more_like_this_shared_terms", + pipeline=[ + {"$search": {"moreLikeThis": {"like": {"title": "the quick brown fox"}}}}, + ], + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 1), + Contains("_id", 3), + Contains("_id", 4), + ] + }, + msg="$search moreLikeThis should return the documents whose covered field shares the " + "example text's significant terms", + ), + StageTestCase( + "more_like_this_score_boost", + pipeline=[ + { + "$search": { + "moreLikeThis": { + "like": {"title": "the quick brown fox"}, + "score": {"boost": {"value": 2.0}}, + } + } + }, + ], + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 1), + Contains("_id", 3), + Contains("_id", 4), + ] + }, + msg="$search moreLikeThis should accept a score modifier and still return its matches", + ), + StageTestCase( + "more_like_this_array_of_docs", + pipeline=[ + { + "$search": { + "moreLikeThis": { + "like": [ + {"title": "the quick brown fox"}, + {"title": "quick rabbit"}, + ] + } + } + }, + ], + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 1), + Contains("_id", 3), + Contains("_id", 4), + ] + }, + msg="$search moreLikeThis should accept an array of example documents and return the " + "documents sharing their significant terms", + ), +] + +SEARCH_TEXT_QUERY_OPERATOR_TESTS = ( + SEARCH_QUERY_STRING_TESTS + SEARCH_TERM_TESTS + SEARCH_MORE_LIKE_THIS_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCH_TEXT_QUERY_OPERATOR_TESTS)) +def test_search_text_query_operators_cases(indexed_collection, test_case: StageTestCase): + """Test $search queryString, term, and moreLikeThis operators.""" + result = execute_command( + indexed_collection, + {"aggregate": indexed_collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult( + result, + expected=test_case.expected, + msg=test_case.msg, + raw_res=True, + ) + + +# Property [queryString Validation]: queryString requires query and defaultPath, +# both string-only (neither accepts the array form that term.query accepts). +SEARCH_QUERY_STRING_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "query_string_missing_query", + pipeline=[{"$search": {"queryString": {"defaultPath": "title"}}}], + error_code=UNKNOWN_ERROR, + msg="$search queryString should reject an operator missing the required query", + ), + StageTestCase( + "query_string_missing_default_path", + pipeline=[{"$search": {"queryString": {"query": "quick"}}}], + error_code=UNKNOWN_ERROR, + msg="$search queryString should reject an operator missing the required defaultPath", + ), + *[ + StageTestCase( + f"query_string_query_non_string_{tid}", + pipeline=[{"$search": {"queryString": {"query": val, "defaultPath": "title"}}}], + error_code=UNKNOWN_ERROR, + msg=f"$search queryString should reject a {tid} query as a non-string", + ) + for tid, val in [ + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("bool", True), + ("object", {"q": "quick"}), + ("array", ["quick"]), + ("objectid", ObjectId("0123456789abcdef01234567")), + ("datetime", datetime.datetime(2020, 1, 1)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ] + ], + *[ + StageTestCase( + f"query_string_default_path_non_string_{tid}", + pipeline=[{"$search": {"queryString": {"query": "quick", "defaultPath": val}}}], + error_code=UNKNOWN_ERROR, + msg=f"$search queryString should reject a {tid} defaultPath as a non-string", + ) + for tid, val in [ + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("bool", True), + ("object", {"value": "title"}), + ("array", ["title"]), + ("objectid", ObjectId("0123456789abcdef01234567")), + ("datetime", datetime.datetime(2020, 1, 1)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ] + ], +] + +# Property [term Validation]: term requires path and query; term.path is a string, +# document, or array of paths, and term.query is a string or array of strings. +SEARCH_TERM_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "term_missing_path", + pipeline=[{"$search": {"term": {"query": "quick"}}}], + error_code=UNKNOWN_ERROR, + msg="$search term should reject an operator missing the required path", + ), + StageTestCase( + "term_missing_query", + pipeline=[{"$search": {"term": {"path": "title"}}}], + error_code=UNKNOWN_ERROR, + msg="$search term should reject an operator missing the required query", + ), + *[ + StageTestCase( + f"term_path_{tid}", + pipeline=[{"$search": {"term": {"path": val, "query": "quick"}}}], + error_code=UNKNOWN_ERROR, + msg=f"$search term should reject a {tid} path as neither a string, document, " + "nor array", + ) + for tid, val in [ + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("bool", True), + ("objectid", ObjectId("0123456789abcdef01234567")), + ("datetime", datetime.datetime(2020, 1, 1)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ] + ], + *[ + StageTestCase( + f"term_query_non_string_{tid}", + pipeline=[{"$search": {"term": {"path": "title", "query": val}}}], + error_code=UNKNOWN_ERROR, + msg=f"$search term should reject a {tid} query as a non-string", + ) + for tid, val in [ + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("bool", True), + ("object", {"q": "quick"}), + ("objectid", ObjectId("0123456789abcdef01234567")), + ("datetime", datetime.datetime(2020, 1, 1)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ] + ], +] + +# Property [moreLikeThis Validation]: moreLikeThis requires like, which must be a +# document or array of documents. +SEARCH_MORE_LIKE_THIS_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "more_like_this_missing_like", + pipeline=[{"$search": {"moreLikeThis": {}}}], + error_code=UNKNOWN_ERROR, + msg="$search moreLikeThis should reject an operator missing the required like", + ), + *[ + StageTestCase( + f"more_like_this_like_{tid}", + pipeline=[{"$search": {"moreLikeThis": {"like": val}}}], + error_code=UNKNOWN_ERROR, + msg=f"$search moreLikeThis should reject a {tid} like as neither a document nor array", + ) + for tid, val in [ + ("string", "quick"), + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("bool", True), + ("objectid", ObjectId("0123456789abcdef01234567")), + ("datetime", datetime.datetime(2020, 1, 1)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ] + ], +] + +SEARCH_TEXT_QUERY_OPERATOR_ERROR_TESTS = ( + SEARCH_QUERY_STRING_ERROR_TESTS + SEARCH_TERM_ERROR_TESTS + SEARCH_MORE_LIKE_THIS_ERROR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCH_TEXT_QUERY_OPERATOR_ERROR_TESTS)) +def test_search_text_query_operators_errors(indexed_collection, test_case: StageTestCase): + """Test $search queryString, term, and moreLikeThis required-field and type validation.""" + result = execute_command( + indexed_collection, + {"aggregate": indexed_collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult(result, error_code=test_case.error_code, msg=test_case.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_unsupported_operators.py b/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_unsupported_operators.py new file mode 100644 index 000000000..3912d5308 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_unsupported_operators.py @@ -0,0 +1,279 @@ +"""Tests for $search recognized-but-unsupported operator validation (hierarchy, vector).""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + UNKNOWN_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = pytest.mark.requires(search=True) + + +# Property [EmbeddedDocument/HasAncestor/HasRoot Validation]: each +# hierarchical-relationship operator rejects a spec that targets a non-embedded +# path or omits its required sub-field with a validation error. +SEARCH_HIERARCHY_OP_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "embedded_document_non_subfield_path", + pipeline=[ + { + "$search": { + "embeddedDocument": { + "path": "title", + "operator": {"text": {"query": "quick", "path": "title"}}, + } + } + }, + ], + error_code=UNKNOWN_ERROR, + msg="$search embeddedDocument should reject a path not mapped as an " + "embeddedDocuments field", + ), + StageTestCase( + "has_ancestor_missing_ancestor_path", + pipeline=[{"$search": {"hasAncestor": {}}}], + error_code=UNKNOWN_ERROR, + msg="$search hasAncestor should reject a spec missing the required ancestorPath", + ), + StageTestCase( + "has_root_missing_operator", + pipeline=[{"$search": {"hasRoot": {}}}], + error_code=UNKNOWN_ERROR, + msg="$search hasRoot should reject a spec missing the required operator", + ), +] + +# Property [Vector-Search Operator Validation]: the vectorSearch and knnBeta +# operators are recognized but reject a non-vector index and any malformed or +# missing required field with a validation error. +SEARCH_VECTOR_OP_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "vector_search_non_vector_index", + pipeline=[ + { + "$search": { + "vectorSearch": { + "path": "title", + "queryVector": [0.1, 0.2, 0.3], + "numCandidates": 10, + "limit": 5, + } + } + }, + ], + error_code=UNKNOWN_ERROR, + msg="$search vectorSearch should reject a path not indexed as vector", + ), + StageTestCase( + "vector_search_index_inside_operator", + pipeline=[ + { + "$search": { + "vectorSearch": { + "path": "title", + "queryVector": [0.1, 0.2, 0.3], + "numCandidates": 10, + "limit": 5, + "index": "default", + } + } + }, + ], + error_code=UNKNOWN_ERROR, + msg="$search vectorSearch should reject an index field placed inside the operator", + ), + StageTestCase( + "vector_search_non_array_query_vector", + pipeline=[ + { + "$search": { + "vectorSearch": { + "path": "title", + "queryVector": "not_an_array", + "numCandidates": 10, + "limit": 5, + } + } + }, + ], + error_code=UNKNOWN_ERROR, + msg="$search vectorSearch should reject a non-array queryVector", + ), + StageTestCase( + "vector_search_string_query_vector", + pipeline=[ + { + "$search": { + "vectorSearch": { + "path": "title", + "queryVector": ["a", "b", "c"], + "numCandidates": 10, + "limit": 5, + } + } + }, + ], + error_code=UNKNOWN_ERROR, + msg="$search vectorSearch should reject a queryVector array of strings", + ), + StageTestCase( + "vector_search_empty_query_vector", + pipeline=[ + { + "$search": { + "vectorSearch": { + "path": "title", + "queryVector": [], + "numCandidates": 10, + "limit": 5, + } + } + }, + ], + error_code=UNKNOWN_ERROR, + msg="$search vectorSearch should reject an empty queryVector", + ), + StageTestCase( + "vector_search_missing_query_vector", + pipeline=[ + {"$search": {"vectorSearch": {"path": "title", "numCandidates": 10, "limit": 5}}}, + ], + error_code=UNKNOWN_ERROR, + msg="$search vectorSearch should reject a spec missing both query and queryVector", + ), + StageTestCase( + "vector_search_missing_path", + pipeline=[ + { + "$search": { + "vectorSearch": { + "queryVector": [0.1, 0.2, 0.3], + "numCandidates": 10, + "limit": 5, + } + } + }, + ], + error_code=UNKNOWN_ERROR, + msg="$search vectorSearch should reject a spec missing the required path", + ), + StageTestCase( + "vector_search_missing_num_candidates", + pipeline=[ + { + "$search": { + "vectorSearch": {"path": "title", "queryVector": [0.1, 0.2, 0.3], "limit": 5} + } + }, + ], + error_code=UNKNOWN_ERROR, + msg="$search vectorSearch should reject a spec missing the required numCandidates", + ), + StageTestCase( + "vector_search_missing_limit", + pipeline=[ + { + "$search": { + "vectorSearch": { + "path": "title", + "queryVector": [0.1, 0.2, 0.3], + "numCandidates": 10, + } + } + }, + ], + error_code=UNKNOWN_ERROR, + msg="$search vectorSearch should reject a spec missing the required limit", + ), + StageTestCase( + "knn_beta_non_knn_vector_index", + pipeline=[ + {"$search": {"knnBeta": {"path": "title", "vector": [0.1, 0.2, 0.3], "k": 5}}}, + ], + error_code=UNKNOWN_ERROR, + msg="$search knnBeta should reject a path not indexed as knnVector", + ), + StageTestCase( + "knn_beta_non_array_vector", + pipeline=[ + {"$search": {"knnBeta": {"path": "title", "vector": "not_an_array", "k": 5}}}, + ], + error_code=UNKNOWN_ERROR, + msg="$search knnBeta should reject a non-array vector", + ), + StageTestCase( + "knn_beta_empty_vector", + pipeline=[ + {"$search": {"knnBeta": {"path": "title", "vector": [], "k": 5}}}, + ], + error_code=UNKNOWN_ERROR, + msg="$search knnBeta should reject an empty vector", + ), + StageTestCase( + "knn_beta_string_vector", + pipeline=[ + {"$search": {"knnBeta": {"path": "title", "vector": ["a", "b", "c"], "k": 5}}}, + ], + error_code=UNKNOWN_ERROR, + msg="$search knnBeta should reject a vector array of strings", + ), + StageTestCase( + "knn_beta_missing_vector", + pipeline=[{"$search": {"knnBeta": {"path": "title", "k": 5}}}], + error_code=UNKNOWN_ERROR, + msg="$search knnBeta should reject a spec missing the required vector", + ), + StageTestCase( + "knn_beta_missing_k", + pipeline=[ + {"$search": {"knnBeta": {"path": "title", "vector": [0.1, 0.2, 0.3]}}}, + ], + error_code=UNKNOWN_ERROR, + msg="$search knnBeta should reject a spec missing the required k", + ), + StageTestCase( + "knn_beta_missing_path", + pipeline=[ + {"$search": {"knnBeta": {"vector": [0.1, 0.2, 0.3], "k": 5}}}, + ], + error_code=UNKNOWN_ERROR, + msg="$search knnBeta should reject a spec missing the required path", + ), +] + +# Property [Nested Search Operator]: the search token is recognized in the +# operator vocabulary but is not a usable operator inside $search, so a nested +# search operator is rejected. +SEARCH_NESTED_SEARCH_OP_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "nested_search_operator", + pipeline=[{"$search": {"search": {"text": {"query": "quick", "path": "title"}}}}], + error_code=UNKNOWN_ERROR, + msg="$search should reject a nested search operator", + ), +] + +SEARCH_UNSUPPORTED_OP_ERROR_TESTS = ( + SEARCH_HIERARCHY_OP_ERROR_TESTS + + SEARCH_VECTOR_OP_ERROR_TESTS + + SEARCH_NESTED_SEARCH_OP_ERROR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCH_UNSUPPORTED_OP_ERROR_TESTS)) +def test_search_unsupported_operator_errors(indexed_collection, test_case: StageTestCase): + """Test $search recognized-but-unsupported hierarchy and vector operators reject their specs.""" + result = execute_command( + indexed_collection, + {"aggregate": indexed_collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult(result, error_code=test_case.error_code, msg=test_case.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_wildcard.py b/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_wildcard.py new file mode 100644 index 000000000..82ea3b3fd --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/search/test_search_wildcard.py @@ -0,0 +1,457 @@ +"""Tests for the $search wildcard operator.""" + +from __future__ import annotations + +import datetime + +import pytest +from bson import Binary, Code, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.stages.search.utils.search_common import ( + create_search_index, +) +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, +) +from documentdb_tests.framework import fixtures +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + UNKNOWN_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import ( + Contains, + Len, +) +from documentdb_tests.framework.test_constants import ( + DECIMAL128_ONE_AND_HALF, +) + +pytestmark = pytest.mark.requires(search=True) + + +_WILDCARD_DOCS = [ + {"_id": 1, "kw": "quick", "std": "the quick brown fox", "tok": "quick"}, + {"_id": 2, "kw": "quack"}, + {"_id": 3, "kw": "axb"}, + {"_id": 4, "kw": "AXB"}, + {"_id": 5, "kw": "a*b"}, # literal asterisk + {"_id": 6, "kw": "a?b"}, # literal question mark + {"_id": 7, "kw": "ab"}, # zero characters between a and b + {"_id": 8, "kw": "axxb"}, # two characters between a and b +] + +_WILDCARD_INDEX_DEFINITION = { + "mappings": { + "dynamic": False, + "fields": { + "kw": {"type": "string", "analyzer": "lucene.keyword"}, + "std": {"type": "string"}, + "tok": {"type": "token"}, + }, + } +} + + +@pytest.fixture(scope="module") +def wildcard_collection(engine_client, worker_id): + """A module-scoped collection with a static search index mapping a + keyword-analyzed, a standard-analyzed, and a token-typed field, shared + read-only across the wildcard cases so the index is built and polled once.""" + db_name = fixtures.generate_database_name("stages_search_wildcard", worker_id) + fixtures.cleanup_database(engine_client, db_name) + db = engine_client[db_name] + coll = db["wildcard"] + coll.insert_many(_WILDCARD_DOCS) + create_search_index(coll, _WILDCARD_INDEX_DEFINITION) + yield coll + fixtures.cleanup_database(engine_client, db_name) + + +# Property [Wildcard Field-Type Matching]: wildcard term-searches a +# keyword-analyzed path without a flag and a standard-analyzed path with +# allowAnalyzedField true, but never matches a token-typed field even with the +# flag (it passes the analyzed-field guard yet is not term-searchable). +SEARCH_WILDCARD_FIELD_TYPE_TESTS: list[StageTestCase] = [ + StageTestCase( + "wildcard_keyword_no_flag", + pipeline=[ + {"$search": {"wildcard": {"query": "qu*", "path": "kw"}}}, + ], + expected={"cursor.firstBatch": [Len(2), Contains("_id", 1), Contains("_id", 2)]}, + msg="$search wildcard should match a keyword-analyzed path with no allowAnalyzedField flag", + ), + StageTestCase( + "wildcard_score_boost", + pipeline=[ + { + "$search": { + "wildcard": {"query": "qu*", "path": "kw", "score": {"boost": {"value": 2.0}}} + } + }, + ], + expected={"cursor.firstBatch": [Len(2), Contains("_id", 1), Contains("_id", 2)]}, + msg="$search wildcard should accept a score modifier and still return its matches", + ), + StageTestCase( + "wildcard_standard_with_flag", + pipeline=[ + {"$search": {"wildcard": {"query": "qu*", "path": "std", "allowAnalyzedField": True}}}, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 1)]}, + msg="$search wildcard should match a standard-analyzed path when allowAnalyzedField " + "is true", + ), + StageTestCase( + "wildcard_token_matches_nothing", + pipeline=[ + {"$search": {"wildcard": {"query": "qu*", "path": "tok", "allowAnalyzedField": True}}}, + ], + expected={"cursor.firstBatch": Len(0)}, + msg="$search wildcard should match nothing on a token-typed field even with " + "allowAnalyzedField true", + ), +] + +# Property [Wildcard Special Characters]: `*` matches zero-or-more characters, +# `?` matches exactly one character, and a backslash-escaped `\*`/`\?` matches a +# literal `*`/`?` character rather than acting as a wildcard. +SEARCH_WILDCARD_SPECIAL_CHAR_TESTS: list[StageTestCase] = [ + StageTestCase( + "wildcard_star_zero_or_more", + pipeline=[ + {"$search": {"wildcard": {"query": "a*b", "path": "kw"}}}, + ], + expected={ + "cursor.firstBatch": [ + Len(5), + Contains("_id", 3), + Contains("_id", 5), + Contains("_id", 6), + Contains("_id", 7), + Contains("_id", 8), + ] + }, + msg="$search wildcard `*` should match zero-or-more characters, including the " + "zero-character and multi-character tokens", + ), + StageTestCase( + "wildcard_question_exactly_one", + pipeline=[ + {"$search": {"wildcard": {"query": "a?b", "path": "kw"}}}, + ], + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 3), + Contains("_id", 5), + Contains("_id", 6), + ] + }, + msg="$search wildcard `?` should match exactly one character, excluding the " + "zero-character and two-character tokens", + ), + StageTestCase( + "wildcard_escaped_star_literal", + pipeline=[ + {"$search": {"wildcard": {"query": "a\\*b", "path": "kw"}}}, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 5)]}, + msg="$search wildcard should match a literal `*` for an escaped `\\*`, not as a wildcard", + ), + StageTestCase( + "wildcard_escaped_question_literal", + pipeline=[ + {"$search": {"wildcard": {"query": "a\\?b", "path": "kw"}}}, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 6)]}, + msg="$search wildcard should match a literal `?` for an escaped `\\?`, not as a wildcard", + ), +] + +# Property [Wildcard Keyword Case Sensitivity]: matching on a keyword path is +# case-sensitive. +SEARCH_WILDCARD_CASE_TESTS: list[StageTestCase] = [ + StageTestCase( + "wildcard_keyword_case_sensitive", + pipeline=[ + {"$search": {"wildcard": {"query": "A*B", "path": "kw"}}}, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 4)]}, + msg="$search wildcard on a keyword path should be case-sensitive, matching only the " + "uppercase-stored token", + ), +] + +# Property [Wildcard Query Array OR]: a query array matches the union of the +# documents matched by each element pattern. +SEARCH_WILDCARD_QUERY_ARRAY_TESTS: list[StageTestCase] = [ + StageTestCase( + "wildcard_query_array_or", + pipeline=[ + {"$search": {"wildcard": {"query": ["qu*", "axb"], "path": "kw"}}}, + ], + expected={ + "cursor.firstBatch": [ + Len(3), + Contains("_id", 1), + Contains("_id", 2), + Contains("_id", 3), + ] + }, + msg="$search wildcard should match the union of a multi-element query array's patterns", + ), +] + +# Property [Wildcard Path Forms]: the path accepts a {value} document, a +# {wildcard} document, and an array of paths in addition to a bare string, each +# resolving to the covered field(s) it names. +SEARCH_WILDCARD_PATH_FORMS_TESTS: list[StageTestCase] = [ + StageTestCase( + "wildcard_path_value_document", + pipeline=[ + {"$search": {"wildcard": {"query": "quick", "path": {"value": "kw"}}}}, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 1)]}, + msg="$search wildcard should accept a {value} path document resolving to the named field", + ), + StageTestCase( + "wildcard_path_wildcard_document", + pipeline=[ + { + "$search": { + "wildcard": { + "query": "quick", + "path": {"wildcard": "*"}, + "allowAnalyzedField": True, + } + } + }, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 1)]}, + msg="$search wildcard should accept a {wildcard} path document spanning every covered " + "field", + ), + StageTestCase( + "wildcard_path_array", + pipeline=[ + {"$search": {"wildcard": {"query": "quick", "path": ["kw"]}}}, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 1)]}, + msg="$search wildcard should accept an array of paths resolving to the named field", + ), +] + +SEARCH_WILDCARD_TESTS = ( + SEARCH_WILDCARD_FIELD_TYPE_TESTS + + SEARCH_WILDCARD_SPECIAL_CHAR_TESTS + + SEARCH_WILDCARD_CASE_TESTS + + SEARCH_WILDCARD_QUERY_ARRAY_TESTS + + SEARCH_WILDCARD_PATH_FORMS_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCH_WILDCARD_TESTS)) +def test_search_wildcard_cases(wildcard_collection, test_case: StageTestCase): + """Test $search wildcard matching over keyword-, standard-, and token-mapped fields.""" + result = execute_command( + wildcard_collection, + {"aggregate": wildcard_collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult( + result, + expected=test_case.expected, + msg=test_case.msg, + raw_res=True, + ) + + +# Property [Wildcard Analyzed Path Rejection]: wildcard rejects a path that +# resolves to a non-keyword analyzed field when allowAnalyzedField is not set, +# including an empty or dotted path that resolves to no keyword field. +SEARCH_WILDCARD_ANALYZED_PATH_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "wildcard_standard_path_no_flag", + pipeline=[ + {"$search": {"wildcard": {"query": "qu*", "path": "std"}}}, + ], + error_code=UNKNOWN_ERROR, + msg="$search wildcard should reject a standard-analyzed path without allowAnalyzedField", + ), + StageTestCase( + "wildcard_empty_path_no_flag", + pipeline=[ + {"$search": {"wildcard": {"query": "qu*", "path": ""}}}, + ], + error_code=UNKNOWN_ERROR, + msg="$search wildcard should reject an empty path that resolves to no keyword field", + ), + StageTestCase( + "wildcard_dotted_path_no_flag", + pipeline=[ + {"$search": {"wildcard": {"query": "qu*", "path": "a.b"}}}, + ], + error_code=UNKNOWN_ERROR, + msg="$search wildcard should reject a dotted path that resolves to no keyword field", + ), +] + +# Property [Wildcard allowAnalyzedField Type]: allowAnalyzedField must be a +# boolean (a null value is treated as the default). +SEARCH_WILDCARD_ALLOW_ANALYZED_TYPE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"wildcard_allow_analyzed_{tid}", + pipeline=[ + {"$search": {"wildcard": {"query": "qu*", "path": "kw", "allowAnalyzedField": val}}}, + ], + error_code=UNKNOWN_ERROR, + msg=f"$search wildcard should reject a {tid} allowAnalyzedField as a non-boolean", + ) + for tid, val in [ + ("string", "true"), + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("object", {"a": 1}), + ("array", [True]), + ("objectid", ObjectId("0123456789abcdef01234567")), + ("datetime", datetime.datetime(2020, 1, 1)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ] +] + +# Property [Wildcard query Validation]: wildcard.query is required and must be a +# non-empty string or array of non-null strings. +SEARCH_WILDCARD_QUERY_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "wildcard_query_missing", + pipeline=[{"$search": {"wildcard": {"path": "kw"}}}], + error_code=UNKNOWN_ERROR, + msg="$search wildcard should reject an operator missing the required query", + ), + StageTestCase( + "wildcard_query_null", + pipeline=[ + {"$search": {"wildcard": {"query": None, "path": "kw"}}}, + ], + error_code=UNKNOWN_ERROR, + msg="$search wildcard should reject a null query treated as missing", + ), + StageTestCase( + "wildcard_query_empty_string", + pipeline=[{"$search": {"wildcard": {"query": "", "path": "kw"}}}], + error_code=UNKNOWN_ERROR, + msg="$search wildcard should reject an empty-string query", + ), + StageTestCase( + "wildcard_query_empty_array", + pipeline=[{"$search": {"wildcard": {"query": [], "path": "kw"}}}], + error_code=UNKNOWN_ERROR, + msg="$search wildcard should reject an empty-array query", + ), + *[ + StageTestCase( + f"wildcard_query_non_string_{tid}", + pipeline=[ + {"$search": {"wildcard": {"query": val, "path": "kw"}}}, + ], + error_code=UNKNOWN_ERROR, + msg=f"$search wildcard should reject a {tid} query as a non-string", + ) + for tid, val in [ + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("bool", True), + ("object", {"q": "quick"}), + ("objectid", ObjectId("0123456789abcdef01234567")), + ("datetime", datetime.datetime(2020, 1, 1)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ] + ], + StageTestCase( + "wildcard_query_array_element_null", + pipeline=[ + {"$search": {"wildcard": {"query": ["qu*", None], "path": "kw"}}}, + ], + error_code=UNKNOWN_ERROR, + msg="$search wildcard should reject a null query-array element", + ), + StageTestCase( + "wildcard_query_array_element_non_string", + pipeline=[ + {"$search": {"wildcard": {"query": ["qu*", 1], "path": "kw"}}}, + ], + error_code=UNKNOWN_ERROR, + msg="$search wildcard should reject a non-string query-array element", + ), +] + +# Property [Wildcard path Validation]: wildcard.path is required and must be a +# string, document, or array of paths. +SEARCH_WILDCARD_PATH_ERROR_TESTS: list[StageTestCase] = [ + *[ + StageTestCase( + f"wildcard_path_{tid}", + pipeline=[{"$search": {"wildcard": {"query": "qu*", "path": val}}}], + error_code=UNKNOWN_ERROR, + msg=f"$search wildcard should reject a {tid} path as neither a document, string, " + "nor array", + ) + for tid, val in [ + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("bool", True), + ("objectid", ObjectId("0123456789abcdef01234567")), + ("datetime", datetime.datetime(2020, 1, 1)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ] + ], + StageTestCase( + "wildcard_path_null", + pipeline=[ + {"$search": {"wildcard": {"query": "qu*", "path": None}}}, + ], + error_code=UNKNOWN_ERROR, + msg="$search wildcard should reject a null path treated as missing", + ), +] + +SEARCH_WILDCARD_ERROR_TESTS = ( + SEARCH_WILDCARD_ANALYZED_PATH_ERROR_TESTS + + SEARCH_WILDCARD_ALLOW_ANALYZED_TYPE_ERROR_TESTS + + SEARCH_WILDCARD_QUERY_ERROR_TESTS + + SEARCH_WILDCARD_PATH_ERROR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCH_WILDCARD_ERROR_TESTS)) +def test_search_wildcard_errors(wildcard_collection, test_case: StageTestCase): + """Test $search wildcard rejects analyzed paths and bad allowAnalyzedField/query/path values.""" + result = execute_command( + wildcard_collection, + {"aggregate": wildcard_collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult(result, error_code=test_case.error_code, msg=test_case.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/search/test_smoke_search.py b/documentdb_tests/compatibility/tests/core/operator/stages/search/test_smoke_search.py index 045c4b3b1..20727273e 100644 --- a/documentdb_tests/compatibility/tests/core/operator/stages/search/test_smoke_search.py +++ b/documentdb_tests/compatibility/tests/core/operator/stages/search/test_smoke_search.py @@ -9,10 +9,9 @@ from documentdb_tests.framework.assertions import assertSuccessPartial from documentdb_tests.framework.executor import execute_command -pytestmark = pytest.mark.smoke +pytestmark = [pytest.mark.smoke, pytest.mark.requires(search=True)] -@pytest.mark.skip(reason="Requires Atlas Search configuration - not available on standard MongoDB") def test_smoke_search(collection): """Test basic $search stage behavior.""" collection.insert_many([{"_id": 1, "title": "test document"}]) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/search/utils/__init__.py b/documentdb_tests/compatibility/tests/core/operator/stages/search/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/search/utils/search_common.py b/documentdb_tests/compatibility/tests/core/operator/stages/search/utils/search_common.py new file mode 100644 index 000000000..e1be36848 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/search/utils/search_common.py @@ -0,0 +1,67 @@ +"""Shared plumbing and corpus for $search stage tests. + +$search tests cannot use the per-case declarative ``docs``/``indexes`` model: a +search index is heavyweight (created, then polled until queryable), so the +corpus and index are built once per package by the package-scoped +``indexed_collection`` fixture (see conftest.py) rather than per test case. This +module holds the create/poll helpers, the shared option constants, and the +dynamic-mapping corpus that fixture populates.""" + +from __future__ import annotations + +import time +from typing import Any + +from pymongo.collection import Collection +from pymongo.operations import SearchIndexModel + +SEARCH_INDEX_NAME = "default" +INDEX_READY_TIMEOUT_SECONDS = 120 + +# Maximum number of query clauses the text operator accepts (inclusive); 'in' has no such cap. +QUERY_CLAUSE_CAP = 1024 + +# Shared corpus for the dynamic-mapping index. Docs 6-18 carry tokens probed only +# by specific analyzer, normalization, token-boundary, and fuzzy cases; none +# contains `quick`/`turtle`, so they do not perturb the matching, scoring, or +# count assertions in the other files. +FIXTURE_DOCS = [ + {"_id": 1, "title": "the quick brown fox", "body": "lazy dog"}, + {"_id": 2, "title": "slow green turtle", "body": "quick nap"}, # `quick` in body + {"_id": 3, "title": "a quick quick quick rabbit"}, # repeats `quick` for the top score + {"_id": 4, "title": "$quick literal dollar"}, # leading `$` matched as literal text + {"_id": 5, "title": "mon résumé est prêt"}, # multi-byte token for highlight spans + {"_id": 6, "title": "x"}, # single-character token + {"_id": 7, "title": "σιγμα"}, # lowercase Greek + {"_id": 8, "title": "день"}, # lowercase Cyrillic + {"_id": 9, "title": "\U00010428"}, # Deseret small letter long I (U+10428) + {"_id": 10, "title": "resume"}, # plain ASCII, distinct from doc 5's résumé + {"_id": 11, "title": "caf\u00e9"}, # precomposed é (U+00E9) + {"_id": 12, "title": "\ufb01le"}, # ligature fi (U+FB01) + le + {"_id": 13, "title": "stra\u00dfe"}, # German eszett (U+00DF) + {"_id": 14, "title": "\u0131rmak"}, # Turkish dotless i (U+0131) + {"_id": 15, "title": "a z"}, # ASCII range-edge letters as separate tokens + {"_id": 16, "title": "word joined"}, # two tokens: word, joined + {"_id": 17, "title": "wordjoined"}, # one token + {"_id": 18, "title": "\u00e9fox"}, # 4 code points / 5 bytes (é is 2 bytes) +] + + +def create_search_index(collection: Collection, definition: dict[str, Any]) -> None: + """Create a search index from a definition and poll until it is queryable.""" + collection.create_search_index(SearchIndexModel(definition=definition, name=SEARCH_INDEX_NAME)) + deadline = time.monotonic() + INDEX_READY_TIMEOUT_SECONDS + while time.monotonic() < deadline: + indexes = list(collection.list_search_indexes(SEARCH_INDEX_NAME)) + if indexes and indexes[0].get("queryable"): + return + time.sleep(1) + raise RuntimeError( + f"search index {SEARCH_INDEX_NAME!r} did not become queryable within " + f"{INDEX_READY_TIMEOUT_SECONDS}s" + ) + + +def create_dynamic_search_index(collection: Collection) -> None: + """Create a dynamic-mapping search index and poll until it is queryable.""" + create_search_index(collection, {"mappings": {"dynamic": True}}) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/test_stages_position_search.py b/documentdb_tests/compatibility/tests/core/operator/stages/test_stages_position_search.py new file mode 100644 index 000000000..dc30c4c75 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/test_stages_position_search.py @@ -0,0 +1,177 @@ +"""Tests for $search pipeline position constraints and stage combinations.""" + +from __future__ import annotations + +import time + +import pytest +from pymongo.operations import SearchIndexModel + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, +) +from documentdb_tests.framework import fixtures +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + FACET_PIPELINE_INVALID_STAGE_ERROR, + NOT_FIRST_STAGE_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = pytest.mark.requires(search=True) + +_POSITION_DOCS = [ + {"_id": 1, "title": "quick brown fox"}, + {"_id": 2, "title": "lazy sleeping dog"}, + {"_id": 3, "title": "green sea turtle"}, +] + + +@pytest.fixture(scope="module") +def position_collection(engine_client, worker_id): + """A module-scoped collection with a ready dynamic search index, shared + read-only across the placement cases so the index is built and polled once + rather than per test. The collection carries a fixed name so the + $unionWith/$lookup sub-pipeline cases can reference it as their source.""" + db_name = fixtures.generate_database_name("stages_search_position", worker_id) + fixtures.cleanup_database(engine_client, db_name) + db = engine_client[db_name] + coll = db["position"] + coll.insert_many(_POSITION_DOCS) + coll.create_search_index( + SearchIndexModel(definition={"mappings": {"dynamic": True}}, name="default") + ) + deadline = time.monotonic() + 120 + while time.monotonic() < deadline: + indexes = list(coll.list_search_indexes("default")) + if indexes and indexes[0].get("queryable"): + break + time.sleep(1) + else: + raise RuntimeError("search index 'default' did not become queryable within 120s") + yield coll + fixtures.cleanup_database(engine_client, db_name) + + +# Property [Sub-pipeline Placement]: the first-stage-only rule is enforced per +# pipeline, so $search is allowed as the first stage of a $unionWith or $lookup +# sub-pipeline and may be followed by other stages within that sub-pipeline. +SEARCH_SUBPIPELINE_PLACEMENT_TESTS: list[StageTestCase] = [ + StageTestCase( + "placement_unionwith_subpipeline", + pipeline=[ + {"$search": {"text": {"query": "quick", "path": "title"}}}, + {"$project": {"_id": 1}}, + { + "$unionWith": { + "coll": "position", + "pipeline": [ + {"$search": {"text": {"query": "turtle", "path": "title"}}}, + {"$project": {"_id": 1}}, + ], + } + }, + ], + expected=[{"_id": 1}, {"_id": 3}], + msg="$search should be allowed as the first stage of a $unionWith sub-pipeline, " + "unioning the sub-pipeline matches with the main-pipeline matches", + ), + StageTestCase( + "placement_lookup_subpipeline", + pipeline=[ + {"$search": {"text": {"query": "quick", "path": "title"}}}, + {"$project": {"_id": 1}}, + { + "$lookup": { + "from": "position", + "pipeline": [ + {"$search": {"text": {"query": "turtle", "path": "title"}}}, + {"$project": {"_id": 1}}, + ], + "as": "joined", + } + }, + ], + expected=[{"_id": 1, "joined": [{"_id": 3}]}], + msg="$search should be allowed as the first stage of a $lookup sub-pipeline and " + "attach the sub-pipeline matches to each joined row", + ), + StageTestCase( + "placement_subpipeline_trailing_stages", + pipeline=[ + {"$search": {"text": {"query": "dog", "path": "title"}}}, + {"$project": {"_id": 1}}, + { + "$unionWith": { + "coll": "position", + "pipeline": [ + {"$search": {"text": {"query": "quick", "path": "title"}}}, + {"$match": {"_id": {"$gt": 0}}}, + {"$project": {"_id": 1}}, + ], + } + }, + ], + expected=[{"_id": 2}, {"_id": 1}], + msg="$search as the first stage of a sub-pipeline should permit trailing stages " + "($match, $project) after it within that sub-pipeline", + ), +] + +# Property [Stage Placement Errors]: $search anywhere other than the first stage +# of its pipeline is rejected with NOT_FIRST_STAGE_ERROR, and $search nested +# inside a $facet stage is rejected with FACET_PIPELINE_INVALID_STAGE_ERROR. +SEARCH_STAGE_PLACEMENT_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "not_first_after_match", + pipeline=[ + {"$match": {"_id": 1}}, + {"$search": {"text": {"query": "quick", "path": "title"}}}, + ], + error_code=NOT_FIRST_STAGE_ERROR, + msg="$search should be rejected when it follows another stage in the pipeline", + ), + StageTestCase( + "not_first_second_search", + pipeline=[ + {"$search": {"text": {"query": "quick", "path": "title"}}}, + {"$search": {"text": {"query": "quick", "path": "title"}}}, + ], + error_code=NOT_FIRST_STAGE_ERROR, + msg="$search should be rejected when a second $search follows the first stage", + ), + StageTestCase( + "in_facet", + pipeline=[ + {"$facet": {"results": [{"$search": {"text": {"query": "quick", "path": "title"}}}]}} + ], + error_code=FACET_PIPELINE_INVALID_STAGE_ERROR, + msg="$search should be rejected when nested inside a $facet stage", + ), +] + +SEARCH_POSITION_TESTS: list[StageTestCase] = ( + SEARCH_SUBPIPELINE_PLACEMENT_TESTS + SEARCH_STAGE_PLACEMENT_ERROR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCH_POSITION_TESTS)) +def test_search_position(position_collection, test_case: StageTestCase): + """Test $search pipeline position constraints, sub-pipeline combinations, and rejections.""" + result = execute_command( + position_collection, + { + "aggregate": position_collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ignore_doc_order=True, + ) From 761e90aec0b4687b84cc3f5aa4103f1bd7d219af Mon Sep 17 00:00:00 2001 From: Daniel Frankcom Date: Mon, 13 Jul 2026 11:53:07 -0700 Subject: [PATCH 20/35] Add vectorSearch stage tests (#649) Signed-off-by: Daniel Frankcom Signed-off-by: RyanGarfinkel <113050972+RyanGarfinkel@users.noreply.github.com> --- .../test_stages_position_vectorSearch.py | 208 +++++++ .../operator/stages/vectorSearch/__init__.py | 0 .../operator/stages/vectorSearch/conftest.py | 228 ++++++++ .../vectorSearch/test_smoke_vectorSearch.py | 38 +- .../test_vectorSearch_core_matching.py | 156 +++++ .../vectorSearch/test_vectorSearch_exact.py | 228 ++++++++ .../test_vectorSearch_explain_options.py | 230 ++++++++ .../vectorSearch/test_vectorSearch_filter.py | 300 ++++++++++ .../test_vectorSearch_filter_parse_errors.py | 202 +++++++ ...st_vectorSearch_filter_predicate_errors.py | 259 ++++++++ ...st_vectorSearch_index_definition_errors.py | 139 +++++ .../test_vectorSearch_index_path.py | 165 ++++++ .../test_vectorSearch_index_path_errors.py | 283 +++++++++ .../vectorSearch/test_vectorSearch_limit.py | 285 +++++++++ .../vectorSearch/test_vectorSearch_nested.py | 551 ++++++++++++++++++ .../test_vectorSearch_num_candidates.py | 325 +++++++++++ .../test_vectorSearch_parent_filter.py | 369 ++++++++++++ .../test_vectorSearch_query_vector.py | 287 +++++++++ .../test_vectorSearch_query_vector_errors.py | 327 +++++++++++ ...test_vectorSearch_required_field_errors.py | 253 ++++++++ .../vectorSearch/test_vectorSearch_scoring.py | 202 +++++++ ...est_vectorSearch_search_node_preference.py | 271 +++++++++ .../test_vectorSearch_stage_basics.py | 218 +++++++ .../test_vectorSearch_stored_source.py | 242 ++++++++ .../stages/vectorSearch/utils/__init__.py | 0 .../vectorSearch/utils/vectorSearch_common.py | 65 +++ documentdb_tests/framework/error_codes.py | 2 + 27 files changed, 5821 insertions(+), 12 deletions(-) create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/test_stages_position_vectorSearch.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/conftest.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_core_matching.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_exact.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_explain_options.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_filter.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_filter_parse_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_filter_predicate_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_index_definition_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_index_path.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_index_path_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_limit.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_nested.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_num_candidates.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_parent_filter.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_query_vector.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_query_vector_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_required_field_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_scoring.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_search_node_preference.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_stage_basics.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_stored_source.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/utils/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/utils/vectorSearch_common.py diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/test_stages_position_vectorSearch.py b/documentdb_tests/compatibility/tests/core/operator/stages/test_stages_position_vectorSearch.py new file mode 100644 index 000000000..5ae609d25 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/test_stages_position_vectorSearch.py @@ -0,0 +1,208 @@ +"""Tests for $vectorSearch pipeline position constraints and stage placement.""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.stages.vectorSearch.utils.vectorSearch_common import ( # noqa: E501 + wait_for_search_index_ready, +) +from documentdb_tests.framework import fixtures +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + FACET_PIPELINE_INVALID_STAGE_ERROR, + LOOKUP_SUB_PIPELINE_NOT_ALLOWED_ERROR, + NOT_FIRST_STAGE_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import DOUBLE_ZERO + +pytestmark = pytest.mark.requires(search=True) + +_POSITION_CORPUS = [ + {"_id": 1, "vec": [1.0, DOUBLE_ZERO, DOUBLE_ZERO]}, + {"_id": 2, "vec": [0.8, 0.2, DOUBLE_ZERO]}, + {"_id": 3, "vec": [0.6, 0.4, DOUBLE_ZERO]}, +] + + +@pytest.fixture(scope="module") +def position_collection(engine_client, worker_id): + """A module-scoped collection with a READY cosine vectorSearch index, shared + read-only across the placement cases so the index is built and polled once + rather than per test. The collection carries a fixed name so the + $unionWith/$lookup sub-pipeline cases can reference it as their source.""" + db_name = fixtures.generate_database_name("stages_vectorSearch_position", worker_id) + fixtures.cleanup_database(engine_client, db_name) + db = engine_client[db_name] + coll = db["position"] + coll.insert_many([dict(doc) for doc in _POSITION_CORPUS]) + db.command( + { + "createSearchIndexes": coll.name, + "indexes": [ + { + "name": "vs_position_index", + "type": "vectorSearch", + "definition": { + "fields": [ + { + "type": "vector", + "path": "vec", + "numDimensions": 3, + "similarity": "cosine", + }, + ] + }, + } + ], + } + ) + wait_for_search_index_ready(coll) + yield coll + fixtures.cleanup_database(engine_client, db_name) + + +# Property [Stage Placement Allowed]: $vectorSearch succeeds as the first stage +# of the main pipeline and as the first stage of a $unionWith sub-pipeline. +VECTORSEARCH_PLACEMENT_TESTS: list[StageTestCase] = [ + StageTestCase( + "first_stage_main_pipeline", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_position_index", + "path": "vec", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 3, + } + }, + {"$project": {"_id": 1}}, + ], + expected=[{"_id": 1}, {"_id": 2}, {"_id": 3}], + msg="$vectorSearch should succeed as the first stage of the main pipeline", + ), + StageTestCase( + "first_stage_union_with_sub_pipeline", + pipeline=[ + {"$match": {"_id": {"$lt": 0}}}, + { + "$unionWith": { + "coll": "position", + "pipeline": [ + { + "$vectorSearch": { + "index": "vs_position_index", + "path": "vec", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 3, + } + }, + {"$project": {"_id": 1}}, + ], + } + }, + ], + expected=[{"_id": 1}, {"_id": 2}, {"_id": 3}], + msg="$vectorSearch should succeed as the first stage of a $unionWith sub-pipeline", + ), +] + +# Property [Stage Placement Errors]: $vectorSearch is rejected when it is not the +# first stage of a pipeline, when nested in a $facet sub-pipeline, and when +# nested in a $lookup sub-pipeline. +VECTORSEARCH_PLACEMENT_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "not_first_stage", + pipeline=[ + {"$match": {"_id": 1}}, + { + "$vectorSearch": { + "index": "vs_position_index", + "path": "vec", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 3, + } + }, + ], + error_code=NOT_FIRST_STAGE_ERROR, + msg="$vectorSearch should be rejected when it is not the first stage", + ), + StageTestCase( + "inside_facet", + pipeline=[ + { + "$facet": { + "results": [ + { + "$vectorSearch": { + "index": "vs_position_index", + "path": "vec", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 3, + } + }, + ], + } + }, + ], + error_code=FACET_PIPELINE_INVALID_STAGE_ERROR, + msg="$vectorSearch should be rejected inside a $facet sub-pipeline", + ), + StageTestCase( + "inside_lookup_sub_pipeline", + pipeline=[ + { + "$lookup": { + "from": "position", + "pipeline": [ + { + "$vectorSearch": { + "index": "vs_position_index", + "path": "vec", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 3, + } + }, + ], + "as": "matches", + } + }, + ], + error_code=LOOKUP_SUB_PIPELINE_NOT_ALLOWED_ERROR, + msg="$vectorSearch should be rejected inside a $lookup sub-pipeline", + ), +] + +VECTORSEARCH_POSITION_TESTS: list[StageTestCase] = ( + VECTORSEARCH_PLACEMENT_TESTS + VECTORSEARCH_PLACEMENT_ERROR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(VECTORSEARCH_POSITION_TESTS)) +def test_vectorSearch_position(position_collection, test_case: StageTestCase): + """Test $vectorSearch pipeline position constraints and rejections.""" + result = execute_command( + position_collection, + { + "aggregate": position_collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/__init__.py b/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/conftest.py b/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/conftest.py new file mode 100644 index 000000000..626bac84a --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/conftest.py @@ -0,0 +1,228 @@ +"""Package-scoped fixtures for $vectorSearch stage tests. + +Each vectorSearch index is heavyweight (created, then polled until READY), so the +corpora and indexes are built once per package here rather than per test file.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Int64 + +from documentdb_tests.framework.test_constants import DOUBLE_ZERO + +from .utils.vectorSearch_common import ( + _FILTER_OID_A, + _FILTER_OID_B, + _FILTER_UUID_A, + _FILTER_UUID_B, + wait_for_search_index_ready, +) + +_VECTOR_CORPUS = [ + { + "_id": 1, + "vc": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "ve": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "vd": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "v8": [1.0] + [DOUBLE_ZERO] * 7, + "meta": {"vec": [1.0, DOUBLE_ZERO, DOUBLE_ZERO]}, + "cat": "x", + "year": 1999, + "count": Int64(10), + "rating": 4.5, + "active": True, + "oid": _FILTER_OID_A, + "uid": _FILTER_UUID_A, + "created": datetime(2020, 1, 1, tzinfo=timezone.utc), + "tags": ["x", "y"], + "opt": "p", + "name": "a", + }, + { + "_id": 2, + "vc": [0.8, 0.2, DOUBLE_ZERO], + "ve": [0.8, 0.2, DOUBLE_ZERO], + "vd": [0.8, 0.2, DOUBLE_ZERO], + "v8": [DOUBLE_ZERO, 1.0] + [DOUBLE_ZERO] * 6, + "meta": {"vec": [0.8, 0.2, DOUBLE_ZERO]}, + "cat": "x", + "year": 2000, + "count": Int64(20), + "rating": 3.0, + "active": False, + "oid": _FILTER_OID_B, + "uid": _FILTER_UUID_B, + "created": datetime(2021, 6, 15, tzinfo=timezone.utc), + "tags": ["y", "z"], + "opt": "p", + "name": "b", + }, + { + "_id": 3, + "vc": [0.6, 0.4, DOUBLE_ZERO], + "ve": [0.6, 0.4, DOUBLE_ZERO], + "vd": [0.6, 0.4, DOUBLE_ZERO], + "v8": [DOUBLE_ZERO] * 2 + [1.0] + [DOUBLE_ZERO] * 5, + "meta": {"vec": [0.6, 0.4, DOUBLE_ZERO]}, + "cat": "y", + "year": 2001, + "count": Int64(30), + "rating": 5.0, + "active": True, + "oid": _FILTER_OID_A, + "uid": _FILTER_UUID_A, + "created": datetime(2022, 12, 31, tzinfo=timezone.utc), + "tags": ["z"], + "opt": None, + "name": "c", + }, + { + "_id": 4, + "vc": [0.2, 0.8, DOUBLE_ZERO], + "ve": [0.2, 0.8, DOUBLE_ZERO], + "vd": [0.2, 0.8, DOUBLE_ZERO], + "v8": [DOUBLE_ZERO] * 3 + [1.0] + [DOUBLE_ZERO] * 4, + "meta": {"vec": [0.2, 0.8, DOUBLE_ZERO]}, + "cat": "y", + "year": 2010, + "count": Int64(40), + "rating": 2.0, + "active": False, + "oid": _FILTER_OID_B, + "uid": _FILTER_UUID_B, + "created": datetime(2023, 3, 3, tzinfo=timezone.utc), + "tags": [], + "name": "d", + }, + { + "_id": 5, + "vc": [DOUBLE_ZERO, 1.0, DOUBLE_ZERO], + "ve": [DOUBLE_ZERO, 1.0, DOUBLE_ZERO], + "vd": [DOUBLE_ZERO, 1.0, DOUBLE_ZERO], + "v8": [DOUBLE_ZERO] * 4 + [1.0] + [DOUBLE_ZERO] * 3, + "meta": {"vec": [DOUBLE_ZERO, 1.0, DOUBLE_ZERO]}, + "cat": "y", + "year": 2010, + "count": Int64(50), + "rating": 4.0, + "active": True, + "oid": _FILTER_OID_A, + "uid": _FILTER_UUID_A, + "created": datetime(2024, 7, 7, tzinfo=timezone.utc), + "tags": ["x"], + "name": "e", + }, +] + +# Mirror each document's cosine vector under a precomposed (NFC) Unicode field +# name (precomposed e-acute, U+00E9) so the index has a real Unicode-named vector +# path to contrast against the decomposed (NFD) query form. +for _doc in _VECTOR_CORPUS: + _doc["caf\u00e9_vec"] = _doc["vc"] + _doc["v8c"] = _doc["v8"] + + +@pytest.fixture(scope="package") +def vector_search_collection(engine_client, worker_id): + """Provide a collection with a READY cosine vectorSearch index over a fixed corpus.""" + db_name = f"vs_core_{worker_id}" + db = engine_client[db_name] + coll = db["vectors"] + db.drop_collection(coll.name) + db.create_collection(coll.name) + coll.insert_many([dict(doc) for doc in _VECTOR_CORPUS]) + db.command( + { + "createSearchIndexes": coll.name, + "indexes": [ + { + "name": "vs_core_index", + "type": "vectorSearch", + "definition": { + "fields": [ + { + "type": "vector", + "path": "vc", + "numDimensions": 3, + "similarity": "cosine", + }, + { + "type": "vector", + "path": "ve", + "numDimensions": 3, + "similarity": "euclidean", + }, + { + "type": "vector", + "path": "vd", + "numDimensions": 3, + "similarity": "dotProduct", + }, + {"type": "filter", "path": "cat"}, + {"type": "filter", "path": "year"}, + {"type": "filter", "path": "count"}, + {"type": "filter", "path": "rating"}, + {"type": "filter", "path": "active"}, + {"type": "filter", "path": "oid"}, + {"type": "filter", "path": "uid"}, + {"type": "filter", "path": "created"}, + {"type": "filter", "path": "tags"}, + {"type": "filter", "path": "opt"}, + { + "type": "vector", + "path": "v8", + "numDimensions": 8, + "similarity": "euclidean", + }, + { + "type": "vector", + "path": "v8c", + "numDimensions": 8, + "similarity": "cosine", + }, + { + "type": "vector", + "path": "meta.vec", + "numDimensions": 3, + "similarity": "cosine", + }, + { + "type": "vector", + "path": "caf\u00e9_vec", + "numDimensions": 3, + "similarity": "cosine", + }, + ] + }, + } + ], + } + ) + wait_for_search_index_ready(coll) + yield coll + engine_client.drop_database(db_name) + + +@pytest.fixture(scope="package") +def vector_search_no_index_collection(engine_client, worker_id): + """Provide an empty collection with no vectorSearch index for spec-error tests.""" + db_name = f"vs_core_noindex_{worker_id}" + db = engine_client[db_name] + coll = db["vectors"] + db.drop_collection(coll.name) + db.create_collection(coll.name) + yield coll + engine_client.drop_database(db_name) + + +@pytest.fixture(scope="package") +def vector_search_absent_collection(engine_client, worker_id): + """Provide a handle to a collection that does not exist (never created).""" + db_name = f"vs_core_absent_{worker_id}" + db = engine_client[db_name] + coll = db["absent_vectors"] + db.drop_collection(coll.name) + yield coll + engine_client.drop_database(db_name) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_smoke_vectorSearch.py b/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_smoke_vectorSearch.py index 83fd9d6b2..e66d1b40c 100644 --- a/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_smoke_vectorSearch.py +++ b/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_smoke_vectorSearch.py @@ -1,7 +1,9 @@ """ Smoke test for $vectorSearch stage. -Tests basic $vectorSearch stage functionality. +Tests basic $vectorSearch stage functionality against a mongot-backed search +target. Gated with requires(search=True) so it is deselected on non-search +targets rather than unconditionally skipped. """ import pytest @@ -9,28 +11,40 @@ from documentdb_tests.framework.assertions import assertSuccess from documentdb_tests.framework.executor import execute_command -pytestmark = pytest.mark.smoke +from .utils.vectorSearch_common import wait_for_search_index_ready +pytestmark = [pytest.mark.smoke, pytest.mark.requires(search=True)] -@pytest.mark.skip(reason="Requires Atlas Search configuration - not available on standard MongoDB") + +@pytest.mark.aggregate def test_smoke_vectorSearch(collection): """Test basic $vectorSearch stage behavior.""" - # Create vector index with vectorOptions + collection.insert_many([{"_id": 1, "name": "test", "embedding": [0.1, 0.2, 0.3]}]) + execute_command( collection, { - "createIndexes": collection.name, + "createSearchIndexes": collection.name, "indexes": [ { - "key": {"embedding": "vector"}, "name": "embedding_vector", - "vectorOptions": {"type": "hnsw", "dimensions": 3.0, "similarity": "euclidean"}, + "type": "vectorSearch", + "definition": { + "fields": [ + { + "type": "vector", + "path": "embedding", + "numDimensions": 3, + "similarity": "euclidean", + } + ] + }, } ], }, ) - collection.insert_many([{"_id": 1, "name": "test", "embedding": [0.1, 0.2, 0.3]}]) + wait_for_search_index_ready(collection) result = execute_command( collection, @@ -39,11 +53,11 @@ def test_smoke_vectorSearch(collection): "pipeline": [ { "$vectorSearch": { - "queryVector": [0.1, 0.2, 0.3], - "path": "embedding", - "numCandidates": 10.0, - "limit": 5.0, "index": "embedding_vector", + "path": "embedding", + "queryVector": [0.1, 0.2, 0.3], + "numCandidates": 10, + "limit": 5, } } ], diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_core_matching.py b/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_core_matching.py new file mode 100644 index 000000000..14916ee3c --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_core_matching.py @@ -0,0 +1,156 @@ +"""Tests for the $vectorSearch stage: core matching and result semantics.""" + +from __future__ import annotations + +import pytest + +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.property_checks import ( + Contains, + Eq, + Len, + NotExists, +) +from documentdb_tests.framework.test_constants import ( + DOUBLE_ZERO, +) + +from .utils.vectorSearch_common import ( + VectorSearchTest, +) + +pytestmark = pytest.mark.requires(search=True) + +# Property [Result Cardinality At Most Limit]: when fewer documents match than +# limit, all available documents are returned with no error and no padding. +VECTORSEARCH_CARDINALITY_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + "limit_exceeds_collection_returns_all", + raw_res=True, + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 100, + "limit": 100, + } + }, + ], + expected={ + "cursor.firstBatch": [ + Len(5), + Contains("_id", 1), + Contains("_id", 2), + Contains("_id", 3), + Contains("_id", 4), + Contains("_id", 5), + ] + }, + msg="$vectorSearch should return all available documents without padding when " + "limit exceeds the collection size", + ), +] + +# Property [Full Document Shape]: by default the stage returns the complete source +# document with its fields intact and no injected similarity-score field. +VECTORSEARCH_FULL_DOCUMENT_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + "full_document_returned", + raw_res=True, + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 1, + } + }, + ], + expected={ + "cursor.firstBatch": Len(1), + "cursor.firstBatch.0._id": Eq(1), + "cursor.firstBatch.0.cat": Eq("x"), + "cursor.firstBatch.0.year": Eq(1999), + "cursor.firstBatch.0.name": Eq("a"), + "cursor.firstBatch.0.vc": Eq([1.0, DOUBLE_ZERO, DOUBLE_ZERO]), + "cursor.firstBatch.0.vectorSearchScore": NotExists(), + "cursor.firstBatch.0.score": NotExists(), + }, + msg="$vectorSearch should return the full source document with no injected " + "score field by default", + ), +] + +# Property [Missing Collection Tolerance]: a well-formed query against a +# nonexistent collection, or an existing empty collection with no index, returns +# zero results with no namespace or index error. +VECTORSEARCH_MISSING_COLLECTION_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + "nonexistent_collection_zero_results", + collection_fixture="vector_search_absent_collection", + raw_res=True, + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + } + }, + ], + expected={"cursor.firstBatch": Len(0)}, + msg="$vectorSearch should return zero results with no error against a " + "nonexistent collection", + ), + VectorSearchTest( + "empty_collection_zero_results", + collection_fixture="vector_search_no_index_collection", + raw_res=True, + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + } + }, + ], + expected={"cursor.firstBatch": Len(0)}, + msg="$vectorSearch should return zero results with no error against an empty " + "collection that has no index", + ), +] + +VECTORSEARCH_CORE_MATCHING_ALL_TESTS = ( + VECTORSEARCH_CARDINALITY_TESTS + + VECTORSEARCH_FULL_DOCUMENT_TESTS + + VECTORSEARCH_MISSING_COLLECTION_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(VECTORSEARCH_CORE_MATCHING_ALL_TESTS)) +def test_vectorSearch_core_matching(test_case: VectorSearchTest, engine_client, request): + """$vectorSearch: core matching and result semantics.""" + coll = request.getfixturevalue(test_case.collection_fixture) + result = execute_command( + coll, + {"aggregate": coll.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + raw_res=test_case.raw_res, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_exact.py b/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_exact.py new file mode 100644 index 000000000..570010c68 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_exact.py @@ -0,0 +1,228 @@ +"""Tests for the $vectorSearch stage: exact ANN/ENN selection.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Code, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) +from bson.binary import Binary + +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + UNKNOWN_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import ( + Eq, + PerDoc, +) +from documentdb_tests.framework.test_constants import ( + DECIMAL128_ONE_AND_HALF, + DOUBLE_ZERO, +) + +from .utils.vectorSearch_common import ( + VectorSearchTest, +) + +pytestmark = pytest.mark.requires(search=True) + +# Property [exact ANN Selection]: when exact is absent (omitted or null) or +# false, ANN runs with numCandidates and returns documents ordered by similarity +# up to limit. +VECTORSEARCH_EXACT_ANN_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + "exact_omitted_defaults_ann", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 3, + } + }, + ], + expected=PerDoc({"_id": Eq(1)}, {"_id": Eq(2)}, {"_id": Eq(3)}), + msg="$vectorSearch should default to ANN and return similarity-ordered " + "documents up to limit when exact is omitted", + ), + VectorSearchTest( + "exact_false_ann", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "exact": False, + "numCandidates": 10, + "limit": 3, + } + }, + ], + expected=PerDoc({"_id": Eq(1)}, {"_id": Eq(2)}, {"_id": Eq(3)}), + msg="$vectorSearch should run ANN and return similarity-ordered documents " + "up to limit when exact is false", + ), + VectorSearchTest( + "exact_null_treated_as_absent", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "exact": None, + "numCandidates": 10, + "limit": 3, + } + }, + ], + expected=PerDoc({"_id": Eq(1)}, {"_id": Eq(2)}, {"_id": Eq(3)}), + msg="$vectorSearch should treat exact null as field-absent and apply ANN " + "when numCandidates is present", + ), +] + +# Property [exact ENN Selection]: exact:true runs ENN and succeeds whether +# numCandidates is omitted or null, returning documents ordered by similarity up +# to limit. +VECTORSEARCH_EXACT_ENN_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + "exact_true_num_candidates_omitted", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "exact": True, + "limit": 3, + } + }, + ], + expected=PerDoc({"_id": Eq(1)}, {"_id": Eq(2)}, {"_id": Eq(3)}), + msg="$vectorSearch should run ENN without numCandidates and return " + "similarity-ordered documents up to limit", + ), + VectorSearchTest( + "exact_true_num_candidates_null", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "exact": True, + "numCandidates": None, + "limit": 3, + } + }, + ], + expected=PerDoc({"_id": Eq(1)}, {"_id": Eq(2)}, {"_id": Eq(3)}), + msg="$vectorSearch should run ENN with numCandidates null and return " + "similarity-ordered documents up to limit", + ), + VectorSearchTest( + "exact_true_scores_match_ann", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "exact": True, + "limit": 5, + } + }, + {"$project": {"_id": 1, "score": {"$meta": "vectorSearchScore"}}}, + ], + expected=[ + {"_id": 1, "score": pytest.approx(1.0)}, + {"_id": 2, "score": pytest.approx(0.9850712418556213)}, + {"_id": 3, "score": pytest.approx(0.9160251617431641)}, + {"_id": 4, "score": pytest.approx(0.6212677955627441)}, + {"_id": 5, "score": pytest.approx(0.5)}, + ], + msg="$vectorSearch ENN should return the same similarity-ordered ids and " + "scores as the equivalent ANN query", + ), +] + +# Property [exact Type Strictness]: a non-boolean exact value of any BSON type is +# rejected as a non-boolean with no coercion of numeric or string truthiness. +VECTORSEARCH_EXACT_TYPE_ERROR_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + f"exact_type_{tid}", + collection_fixture="vector_search_no_index_collection", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "exact": val, + "numCandidates": 10, + "limit": 5, + } + } + ], + error_code=UNKNOWN_ERROR, + msg=f"$vectorSearch should reject a {tid} exact value as a non-boolean", + ) + for tid, val in [ + ("int32_one", 1), + ("int32_zero", 0), + ("int64", Int64(1)), + ("double_one", 1.0), + ("double_zero", DOUBLE_ZERO), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("string_true", "true"), + ("string_empty", ""), + ("array", [True]), + ("object", {"a": 1}), + ("objectid", ObjectId("5a9427648b0beebeb69537a5")), + ("datetime", datetime(2020, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +VECTORSEARCH_EXACT_ALL_TESTS = ( + VECTORSEARCH_EXACT_ANN_TESTS + + VECTORSEARCH_EXACT_ENN_TESTS + + VECTORSEARCH_EXACT_TYPE_ERROR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(VECTORSEARCH_EXACT_ALL_TESTS)) +def test_vectorSearch_exact(test_case: VectorSearchTest, engine_client, request): + """$vectorSearch: exact ANN/ENN selection.""" + coll = request.getfixturevalue(test_case.collection_fixture) + result = execute_command( + coll, + {"aggregate": coll.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_explain_options.py b/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_explain_options.py new file mode 100644 index 000000000..986c23c01 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_explain_options.py @@ -0,0 +1,230 @@ +"""Tests for the $vectorSearch stage: explainOptions behavior and errors.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Code, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) +from bson.binary import Binary + +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + UNKNOWN_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import ( + Eq, + Exists, +) +from documentdb_tests.framework.test_constants import ( + DECIMAL128_ONE_AND_HALF, + DOUBLE_ZERO, +) + +from .utils.vectorSearch_common import ( + VectorSearchTest, +) + +pytestmark = pytest.mark.requires(search=True) + +# Property [explainOptions Trace Element Type Non-Validation]: on a genuine +# explain aggregate explainOptions.traceDocumentIds accepts an element of any +# non-null BSON type (the documented "array of objectIDs" is not enforced) and +# echoes the supplied ids back into the explain output. +VECTORSEARCH_EXPLAIN_OPTIONS_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + f"explain_options_{tid}", + explain=True, + raw_res=True, + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 3, + "explainOptions": {"traceDocumentIds": ids}, + } + } + ], + expected={ + "ok": Eq(1.0), + # pymongo decodes a subtype-0 BSON binary element to plain bytes, + # whose equality with the Binary input is subtype-sensitive, so the + # binary case asserts the echoed field is present rather than equal. + "stages.0.$vectorSearch.explainOptions.traceDocumentIds": ( + Exists() if tid == "binary" else Eq(ids) + ), + }, + msg=f"$vectorSearch should accept a {tid} traceDocumentIds element on a " + "genuine explain and echo it into the explain output", + ) + for tid, ids in [ + ("objectid", [ObjectId("5a9427648b0beebeb69537a5"), ObjectId("5a9427648b0beebeb69537b6")]), + ("int32", [1, 2]), + ("int64", [Int64(1), Int64(2)]), + ("double", [1.5, 2.5]), + ("decimal128", [DECIMAL128_ONE_AND_HALF]), + ("string", ["x", "y"]), + ("bool", [True, False]), + ("object", [{"a": 1}]), + ("array", [[1, 2]]), + ("datetime", [datetime(2020, 1, 1, tzinfo=timezone.utc)]), + ("timestamp", [Timestamp(1, 1)]), + ("binary", [Binary(b"\x01\x02\x03")]), + ("regex", [Regex(".*", "i")]), + ("code", [Code("function(){}")]), + ("minkey", [MinKey()]), + ("maxkey", [MaxKey()]), + ("mixed", [1, "x", 1.5, ObjectId("5a9427648b0beebeb69537a5")]), + ] +] + +# Property [explainOptions Structure Validation]: explainOptions and its +# traceDocumentIds sub-field are structurally validated independent of index +# existence, so a malformed shape is rejected against a collection with no index. +VECTORSEARCH_EXPLAIN_OPTIONS_ERROR_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + f"explain_options_{tid}", + collection_fixture="vector_search_no_index_collection", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + "explainOptions": explain_options, + } + } + ], + error_code=UNKNOWN_ERROR, + msg=msg, + ) + for tid, explain_options, msg in [ + ( + "non_document", + 5, + "$vectorSearch should reject a non-document explainOptions value", + ), + ( + "unknown_subfield", + {"bogus": 1}, + "$vectorSearch should reject an unknown explainOptions sub-field", + ), + ( + "trace_document_ids_non_array", + {"traceDocumentIds": 5}, + "$vectorSearch should reject a non-array traceDocumentIds", + ), + ( + "trace_document_ids_empty", + {"traceDocumentIds": []}, + "$vectorSearch should reject an empty traceDocumentIds array", + ), + ] +] + +# Property [explainOptions Requires Explain Mode]: explainOptions present on a +# non-explain query is rejected once the index resolves, because the option is +# only valid when the query is run in explain mode. +VECTORSEARCH_EXPLAIN_OPTIONS_NON_EXPLAIN_ERROR_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + "explain_options_non_explain_query", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + "explainOptions": {"traceDocumentIds": [1, 2]}, + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$vectorSearch should reject explainOptions on a non-explain query", + ), +] + +# Property [explainOptions Requires Non-Empty traceDocumentIds]: on a genuine +# explain query, explainOptions must carry a non-empty traceDocumentIds, so +# omitting it entirely and supplying a null element are both rejected. +VECTORSEARCH_EXPLAIN_OPTIONS_EXPLAIN_ERROR_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + "explain_options_empty_document", + explain=True, + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + "explainOptions": {}, + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$vectorSearch should reject an explainOptions with no traceDocumentIds " + "on an explain query", + ), + VectorSearchTest( + "explain_options_trace_null_element", + explain=True, + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + "explainOptions": {"traceDocumentIds": [None]}, + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$vectorSearch should reject a null traceDocumentIds element on an explain query", + ), +] + +VECTORSEARCH_EXPLAIN_OPTIONS_ALL_TESTS = ( + VECTORSEARCH_EXPLAIN_OPTIONS_TESTS + + VECTORSEARCH_EXPLAIN_OPTIONS_ERROR_TESTS + + VECTORSEARCH_EXPLAIN_OPTIONS_NON_EXPLAIN_ERROR_TESTS + + VECTORSEARCH_EXPLAIN_OPTIONS_EXPLAIN_ERROR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(VECTORSEARCH_EXPLAIN_OPTIONS_ALL_TESTS)) +def test_vectorSearch_explain_options(test_case: VectorSearchTest, engine_client, request): + """$vectorSearch: explainOptions behavior and errors.""" + coll = request.getfixturevalue(test_case.collection_fixture) + aggregate = {"aggregate": coll.name, "pipeline": test_case.pipeline, "cursor": {}} + command = ( + {"explain": aggregate, "verbosity": "queryPlanner"} if test_case.explain else aggregate + ) + result = execute_command(coll, command) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + raw_res=test_case.raw_res, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_filter.py b/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_filter.py new file mode 100644 index 000000000..bcbbca4a0 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_filter.py @@ -0,0 +1,300 @@ +"""Tests for the $vectorSearch stage: filter pre-filtering.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Int64, +) + +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.property_checks import ( + Contains, + Len, +) +from documentdb_tests.framework.test_constants import ( + DOUBLE_ZERO, +) + +from .utils.vectorSearch_common import ( + _FILTER_OID_A, + _FILTER_UUID_A, + VectorSearchTest, +) + +pytestmark = pytest.mark.requires(search=True) + +# Property [filter Match All]: an empty filter document applies no predicate and +# retains every document. +VECTORSEARCH_FILTER_MATCH_ALL_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + "match_all_empty_filter", + raw_res=True, + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + "filter": {}, + } + }, + ], + expected={ + "cursor.firstBatch": [ + Len(5), + Contains("_id", 1), + Contains("_id", 2), + Contains("_id", 3), + Contains("_id", 4), + Contains("_id", 5), + ] + }, + msg="$vectorSearch should retain every document for an empty filter", + ), +] + +# Property [filter Per-Field Operators]: each supported per-field operator, and +# the $eq shorthand, pre-filters to exactly the documents matching that predicate. +VECTORSEARCH_FILTER_OPERATOR_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + f"operator_{tid}", + raw_res=True, + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + "filter": flt, + } + }, + ], + expected={"cursor.firstBatch": [Len(len(ids)), *(Contains("_id", i) for i in ids)]}, + msg=f"$vectorSearch should pre-filter with the {tid} operator", + ) + for tid, flt, ids in [ + ("shorthand_eq", {"year": 2001}, [3]), + ("eq", {"year": {"$eq": 2010}}, [4, 5]), + ("ne", {"year": {"$ne": 2010}}, [1, 2, 3]), + ("gt", {"year": {"$gt": 2000}}, [3, 4, 5]), + ("gte", {"year": {"$gte": 2001}}, [3, 4, 5]), + ("lt", {"year": {"$lt": 2001}}, [1, 2]), + ("lte", {"year": {"$lte": 2000}}, [1, 2]), + ("in", {"year": {"$in": [1999, 2001]}}, [1, 3]), + ("nin", {"year": {"$nin": [1999, 2001]}}, [2, 4, 5]), + ("exists", {"cat": {"$exists": True}}, [1, 2, 3, 4, 5]), + ("not", {"year": {"$not": {"$gt": 2000}}}, [1, 2]), + ] +] + +# Property [filter Combinators]: top-level $and, $or, $nor, and implicit +# multi-field AND compose their arms into the correct combined predicate. +VECTORSEARCH_FILTER_COMBINATOR_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + f"combinator_{tid}", + raw_res=True, + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + "filter": flt, + } + }, + ], + expected={"cursor.firstBatch": [Len(len(ids)), *(Contains("_id", i) for i in ids)]}, + msg=f"$vectorSearch should compose the {tid} combinator correctly", + ) + for tid, flt, ids in [ + ("and", {"$and": [{"cat": "y"}, {"year": {"$gte": 2010}}]}, [4, 5]), + ("or", {"$or": [{"cat": "x"}, {"year": 2001}]}, [1, 2, 3]), + ("nor", {"$nor": [{"cat": "x"}]}, [3, 4, 5]), + ("implicit_and", {"cat": "y", "active": True}, [3, 5]), + ] +] + +# Property [filter Value Types]: each supported predicate value type pre-filters +# correctly, including null as a direct value and element membership on an +# array-valued field. +VECTORSEARCH_FILTER_VALUE_TYPE_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + f"value_type_{tid}", + raw_res=True, + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + "filter": flt, + } + }, + ], + expected={"cursor.firstBatch": [Len(len(ids)), *(Contains("_id", i) for i in ids)]}, + msg=f"$vectorSearch should pre-filter with a {tid} predicate value", + ) + for tid, flt, ids in [ + ("string", {"cat": "x"}, [1, 2]), + ("int32", {"year": 2000}, [2]), + ("int64", {"count": Int64(20)}, [2]), + ("double", {"rating": 4.5}, [1]), + ("boolean", {"active": True}, [1, 3, 5]), + ("object_id", {"oid": _FILTER_OID_A}, [1, 3, 5]), + ("date", {"created": {"$gt": datetime(2023, 1, 1, tzinfo=timezone.utc)}}, [4, 5]), + ("uuid", {"uid": _FILTER_UUID_A}, [1, 3, 5]), + ("null_direct", {"opt": None}, [3]), + ("array_element_membership", {"tags": "x"}, [1, 5]), + ] +] + +# Property [filter Numeric In Mixing]: a $in/$nin list mixing int and double +# elements is accepted as same-type numeric and brackets by numeric value. +VECTORSEARCH_FILTER_NUMERIC_IN_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + f"numeric_in_{tid}", + raw_res=True, + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + "filter": flt, + } + }, + ], + expected={"cursor.firstBatch": [Len(len(ids)), *(Contains("_id", i) for i in ids)]}, + msg=f"$vectorSearch should accept a mixed int/double {tid} list", + ) + for tid, flt, ids in [ + ("in", {"rating": {"$in": [4.0, 5]}}, [3, 5]), + ("nin", {"rating": {"$nin": [4.0, 5]}}, [1, 2, 4]), + ] +] + +# Property [filter Cross-Type Bracketing]: a predicate whose value type differs +# from the indexed field's type brackets to no match without erroring. +VECTORSEARCH_FILTER_CROSS_TYPE_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + "cross_type_string_vs_numeric", + raw_res=True, + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + "filter": {"year": {"$gt": "a"}}, + } + }, + ], + expected={"cursor.firstBatch": Len(0)}, + msg="$vectorSearch should return no results for a cross-type comparison without erroring", + ), +] + +# Property [filter Limit And Mode]: the filter is applied before limit, a filter +# matching nothing yields an empty result, and filtering is identical under ANN +# and ENN. +VECTORSEARCH_FILTER_LIMIT_MODE_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + "applied_before_limit", + raw_res=True, + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 1, + "filter": {"cat": "y"}, + } + }, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 3)]}, + msg="$vectorSearch should apply the filter before limit truncates by score", + ), + VectorSearchTest( + "matches_nothing_empty", + raw_res=True, + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + "filter": {"year": 3000}, + } + }, + ], + expected={"cursor.firstBatch": Len(0)}, + msg="$vectorSearch should return an empty result for a filter that matches nothing", + ), + VectorSearchTest( + "enn_filter", + raw_res=True, + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "exact": True, + "limit": 5, + "filter": {"cat": "x"}, + } + }, + ], + expected={"cursor.firstBatch": [Len(2), Contains("_id", 1), Contains("_id", 2)]}, + msg="$vectorSearch should apply the filter identically under ENN", + ), +] + +VECTORSEARCH_FILTER_ALL_TESTS = ( + VECTORSEARCH_FILTER_MATCH_ALL_TESTS + + VECTORSEARCH_FILTER_OPERATOR_TESTS + + VECTORSEARCH_FILTER_COMBINATOR_TESTS + + VECTORSEARCH_FILTER_VALUE_TYPE_TESTS + + VECTORSEARCH_FILTER_NUMERIC_IN_TESTS + + VECTORSEARCH_FILTER_CROSS_TYPE_TESTS + + VECTORSEARCH_FILTER_LIMIT_MODE_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(VECTORSEARCH_FILTER_ALL_TESTS)) +def test_vectorSearch_filter(test_case: VectorSearchTest, engine_client, request): + """$vectorSearch: filter pre-filtering.""" + coll = request.getfixturevalue(test_case.collection_fixture) + result = execute_command( + coll, + {"aggregate": coll.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + raw_res=test_case.raw_res, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_filter_parse_errors.py b/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_filter_parse_errors.py new file mode 100644 index 000000000..11943e3f2 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_filter_parse_errors.py @@ -0,0 +1,202 @@ +"""Tests for the $vectorSearch stage: filter parse and shape errors.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Code, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) +from bson.binary import Binary + +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + EXPRESSION_NOT_OBJECT_ERROR, + NEAR_NOT_ALLOWED_ERROR, + UNKNOWN_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_ONE_AND_HALF, + DOUBLE_ZERO, +) + +from .utils.vectorSearch_common import ( + VectorSearchTest, +) + +pytestmark = pytest.mark.requires(search=True) + +# Property [filter Scalar Type Rejection]: a non-object, non-array scalar filter +# value is rejected at parse time as not an object. +VECTORSEARCH_FILTER_SCALAR_ERROR_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + f"filter_scalar_{tid}", + collection_fixture="vector_search_no_index_collection", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + "filter": val, + } + } + ], + error_code=EXPRESSION_NOT_OBJECT_ERROR, + msg=f"$vectorSearch should reject a {tid} scalar filter value as not an object", + ) + for tid, val in [ + ("string", "x"), + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool", True), + ("objectid", ObjectId("5a9427648b0beebeb69537a5")), + ("datetime", datetime(2020, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [filter Array Rejection]: an array filter value is rejected as not a +# document rather than treated as an empty filter. +VECTORSEARCH_FILTER_ARRAY_ERROR_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + "filter_array_empty", + collection_fixture="vector_search_no_index_collection", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + "filter": [], + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$vectorSearch should reject an empty-array filter as not a document", + ), +] + +# Property [filter MQL Parse Rejection]: a filter operator rejected by the MQL +# parser surfaces a parse error rather than an executor validation error. +VECTORSEARCH_FILTER_MQL_ERROR_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + f"filter_mql_{tid}", + collection_fixture="vector_search_no_index_collection", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + "filter": flt, + } + } + ], + error_code=BAD_VALUE_ERROR, + msg=f"$vectorSearch should reject a {tid} filter as an MQL parse error", + ) + for tid, flt in [ + ("text", {"$text": {"$search": "x"}}), + ("comment", {"year": {"$comment": "x"}}), + ("unknown_top_level", {"$bad": 1}), + ] +] + +# Property [filter Geo Operator Rejection]: a geospatial filter operator is +# rejected because it requires sorting geospatial data. +VECTORSEARCH_FILTER_GEO_ERROR_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + "filter_geo_near", + collection_fixture="vector_search_no_index_collection", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + "filter": {"year": {"$near": [0, 0]}}, + } + } + ], + error_code=NEAR_NOT_ALLOWED_ERROR, + msg="$vectorSearch should reject a geo operator in a filter", + ), +] + +# Property [filter Combinator Argument Validation]: a top-level logical +# combinator requires a non-empty array argument. +VECTORSEARCH_FILTER_COMBINATOR_ERROR_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + f"filter_combinator_{tid}", + collection_fixture="vector_search_no_index_collection", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + "filter": flt, + } + } + ], + error_code=BAD_VALUE_ERROR, + msg=f"$vectorSearch should reject a {tid} combinator argument", + ) + for tid, flt in [ + ("empty_array", {"$and": []}), + ("non_array", {"$and": {"year": 1}}), + ] +] + +VECTORSEARCH_FILTER_PARSE_ERRORS_ALL_TESTS = ( + VECTORSEARCH_FILTER_SCALAR_ERROR_TESTS + + VECTORSEARCH_FILTER_ARRAY_ERROR_TESTS + + VECTORSEARCH_FILTER_MQL_ERROR_TESTS + + VECTORSEARCH_FILTER_GEO_ERROR_TESTS + + VECTORSEARCH_FILTER_COMBINATOR_ERROR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(VECTORSEARCH_FILTER_PARSE_ERRORS_ALL_TESTS)) +def test_vectorSearch_filter_parse_errors(test_case: VectorSearchTest, engine_client, request): + """$vectorSearch: filter parse and shape errors.""" + coll = request.getfixturevalue(test_case.collection_fixture) + result = execute_command( + coll, + {"aggregate": coll.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_filter_predicate_errors.py b/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_filter_predicate_errors.py new file mode 100644 index 000000000..38b7ee482 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_filter_predicate_errors.py @@ -0,0 +1,259 @@ +"""Tests for the $vectorSearch stage: filter predicate errors.""" + +from __future__ import annotations + +import pytest +from bson import ( + Code, + MaxKey, + MinKey, + Regex, + Timestamp, +) +from bson.binary import Binary + +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + UNKNOWN_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_ONE_AND_HALF, + DOUBLE_ZERO, +) + +from .utils.vectorSearch_common import ( + VectorSearchTest, +) + +pytestmark = pytest.mark.requires(search=True) + +# Property [filter Unsupported Operator Rejection]: a parseable filter operator +# that is not a supported comparison operator is rejected, each surfacing its own +# diagnostic message. +VECTORSEARCH_FILTER_OPERATOR_ERROR_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + "filter_operator_regex", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + "filter": {"year": {"$regex": "x"}}, + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$vectorSearch should reject an unsupported per-field filter operator", + ), + VectorSearchTest( + "filter_operator_expr", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + "filter": {"$expr": {"$gt": ["$year", 1]}}, + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$vectorSearch should reject a $expr filter operator", + ), + VectorSearchTest( + "filter_operator_json_schema", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + "filter": {"$jsonSchema": {"required": ["year"]}}, + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$vectorSearch should reject a $jsonSchema filter operator", + ), +] + +# Property [filter Value Type Rejection]: a predicate value whose BSON type is +# not among the supported filter value types is rejected. +VECTORSEARCH_FILTER_VALUE_TYPE_ERROR_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + f"filter_value_type_{tid}", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + "filter": {"year": val}, + } + } + ], + error_code=UNKNOWN_ERROR, + msg=f"$vectorSearch should reject a {tid} filter value as an unsupported type", + ) + for tid, val in [ + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("object", {"a": 1}), + ("array", [1, 2]), + ("timestamp", Timestamp(1, 1)), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("non_uuid_binary", Binary(b"\x01\x02\x03")), + ("minkey", {"$gt": MinKey()}), + ("maxkey", {"$gt": MaxKey()}), + ] +] + +# Property [filter Field Not Indexed]: a filter referencing a field not indexed +# as the filter type is rejected, and a dollar-prefixed key is parsed as a +# top-level operator rather than a field name. +VECTORSEARCH_FILTER_FIELD_NOT_INDEXED_ERROR_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + "filter_field_not_indexed", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + "filter": {"_id": 1}, + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$vectorSearch should reject a filter on a field not indexed as the filter type", + ), + VectorSearchTest( + "filter_dollar_prefixed_key", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + "filter": {"$year": 1}, + } + } + ], + error_code=BAD_VALUE_ERROR, + msg="$vectorSearch should parse a dollar-prefixed filter key as a top-level " + "operator and reject it", + ), +] + +# Property [filter Element Constraints]: $in element lists must be non-empty, +# same-type, and free of null elements, and $exists requires a boolean argument. +VECTORSEARCH_FILTER_ELEMENT_ERROR_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + "filter_in_empty", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + "filter": {"year": {"$in": []}}, + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$vectorSearch should reject an empty $in list in a filter", + ), + VectorSearchTest( + "filter_in_mixed_type", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + "filter": {"year": {"$in": [1, "x"]}}, + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$vectorSearch should reject a mixed-type $in list in a filter", + ), + VectorSearchTest( + "filter_in_null_element", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + "filter": {"year": {"$in": [None]}}, + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$vectorSearch should reject a null element in a filter $in list", + ), + VectorSearchTest( + "filter_exists_non_boolean", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + "filter": {"year": {"$exists": 1}}, + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$vectorSearch should reject a non-boolean $exists argument in a filter", + ), +] + +VECTORSEARCH_FILTER_PREDICATE_ERRORS_ALL_TESTS = ( + VECTORSEARCH_FILTER_OPERATOR_ERROR_TESTS + + VECTORSEARCH_FILTER_VALUE_TYPE_ERROR_TESTS + + VECTORSEARCH_FILTER_FIELD_NOT_INDEXED_ERROR_TESTS + + VECTORSEARCH_FILTER_ELEMENT_ERROR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(VECTORSEARCH_FILTER_PREDICATE_ERRORS_ALL_TESTS)) +def test_vectorSearch_filter_predicate_errors(test_case: VectorSearchTest, engine_client, request): + """$vectorSearch: filter predicate errors.""" + coll = request.getfixturevalue(test_case.collection_fixture) + result = execute_command( + coll, + {"aggregate": coll.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_index_definition_errors.py b/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_index_definition_errors.py new file mode 100644 index 000000000..85d5c7bcc --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_index_definition_errors.py @@ -0,0 +1,139 @@ +"""Tests for the $vectorSearch stage: vectorSearch index definition errors. + +These are createSearchIndexes-time validation errors for definition options that +the $vectorSearch surface owns (nestedRoot, storedSource, and the vector field's +numDimensions, against which a query vector's length is checked). They fail +synchronously at index-create time, so unlike the query-path tests they do not +build a READY index or run an aggregate.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +import pytest + +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + COMMAND_FAILED_ERROR, + UNKNOWN_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_case import BaseTestCase +from documentdb_tests.framework.test_constants import DOUBLE_ZERO + +pytestmark = pytest.mark.requires(search=True) + + +@dataclass(frozen=True) +class IndexDefinitionErrorTest(BaseTestCase): + """A vectorSearch index definition that createSearchIndexes must reject.""" + + definition: dict[str, Any] = field(default_factory=dict) + + +# Property [nestedRoot Path Mismatch]: a vectorSearch index whose nestedRoot does +# not name any vector field path in the definition is rejected at index-create time. +VECTORSEARCH_NESTED_ROOT_MISMATCH_ERROR_TESTS: list[IndexDefinitionErrorTest] = [ + IndexDefinitionErrorTest( + "nested_root_does_not_match_vector_path", + definition={ + "nestedRoot": "nonexistent", + "fields": [ + { + "type": "vector", + "path": "reviews.embedding", + "numDimensions": 3, + "similarity": "cosine", + } + ], + }, + error_code=COMMAND_FAILED_ERROR, + msg="createSearchIndexes should reject a vectorSearch index whose nestedRoot " + "does not match any vector field path", + ), +] + +# Property [storedSource true Unsupported]: a vectorSearch index defined with +# storedSource: true is rejected at index-create time, because only include, +# exclude, or false are accepted. +VECTORSEARCH_STORED_SOURCE_TRUE_ERROR_TESTS: list[IndexDefinitionErrorTest] = [ + IndexDefinitionErrorTest( + "stored_source_true_unsupported", + definition={ + "storedSource": True, + "fields": [ + { + "type": "vector", + "path": "embedding", + "numDimensions": 3, + "similarity": "cosine", + } + ], + }, + error_code=UNKNOWN_ERROR, + msg="createSearchIndexes should reject a vectorSearch index defined with " + "storedSource true", + ), +] + +# Property [numDimensions Bounds]: a vector field whose numDimensions falls +# outside the accepted range is rejected at index-create time, asserted at both +# boundaries. +VECTORSEARCH_NUM_DIMENSIONS_BOUNDS_ERROR_TESTS: list[IndexDefinitionErrorTest] = [ + IndexDefinitionErrorTest( + f"num_dimensions_bounds_{tid}", + definition={ + "fields": [ + { + "type": "vector", + "path": "embedding", + "numDimensions": ndim, + "similarity": "cosine", + } + ], + }, + error_code=UNKNOWN_ERROR, + msg=f"createSearchIndexes should reject a vector field with a {tid} " + "numDimensions as out of bounds", + ) + for tid, ndim in [ + ("below_lower", 0), + ("above_upper", 8193), + ] +] + +VECTORSEARCH_INDEX_DEFINITION_ERROR_TESTS = ( + VECTORSEARCH_NESTED_ROOT_MISMATCH_ERROR_TESTS + + VECTORSEARCH_STORED_SOURCE_TRUE_ERROR_TESTS + + VECTORSEARCH_NUM_DIMENSIONS_BOUNDS_ERROR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(VECTORSEARCH_INDEX_DEFINITION_ERROR_TESTS)) +def test_vectorSearch_index_definition_errors(test_case: IndexDefinitionErrorTest, collection): + """$vectorSearch: vectorSearch index definition errors.""" + collection.insert_one( + { + "_id": 1, + "embedding": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "reviews": [{"embedding": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], "rating": 5}], + } + ) + result = execute_command( + collection, + { + "createSearchIndexes": collection.name, + "indexes": [ + {"name": "vidx", "type": "vectorSearch", "definition": test_case.definition} + ], + }, + ) + assertResult( + result, + error_code=test_case.error_code, + msg=test_case.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_index_path.py b/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_index_path.py new file mode 100644 index 000000000..14a61b1e9 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_index_path.py @@ -0,0 +1,165 @@ +"""Tests for the $vectorSearch stage: index and path resolution.""" + +from __future__ import annotations + +import pytest + +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.property_checks import ( + Eq, + Len, + PerDoc, +) +from documentdb_tests.framework.test_constants import ( + DOUBLE_ZERO, +) + +from .utils.vectorSearch_common import ( + VectorSearchTest, +) + +pytestmark = pytest.mark.requires(search=True) + +# Property [index Exact Name Match]: the correct existing index name resolves and +# returns the collection's similarity-ordered documents. +VECTORSEARCH_INDEX_MATCH_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + "match_correct_name", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + } + }, + ], + expected=PerDoc( + {"_id": Eq(1)}, {"_id": Eq(2)}, {"_id": Eq(3)}, {"_id": Eq(4)}, {"_id": Eq(5)} + ), + msg="$vectorSearch should resolve the correct existing index name and return results", + ), +] + +# Property [index Name Silent Miss]: an index name that is not byte-for-byte the +# existing index name returns zero results with no error, because matching is +# exact and literal rather than fuzzy or expression-evaluated. +VECTORSEARCH_INDEX_SILENT_MISS_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + f"silent_miss_{tid}", + raw_res=True, + pipeline=[ + { + "$vectorSearch": { + "index": name, + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + } + }, + ], + expected={"cursor.firstBatch": Len(0)}, + msg=f"$vectorSearch should silently return zero results for a {tid} index name", + ) + for tid, name in [ + ("nonexistent", "no_such_index"), + ("case_variant", "vs_core_index".upper()), + ("leading_space", " vs_core_index"), + ("trailing_space", "vs_core_index "), + ("tab", "vs_core_index\t"), + ("newline", "vs_core_index\n"), + ("dollar_prefix", "$vs_core_index"), + ("dollar_only", "$"), + ("dollar_now_variable", "$$NOW"), + ("double_dollar", "$$"), + ("null_byte", "vs_core_index\x00"), + ("control_char", "vs\x01core"), + ("punctuation", '{vs}"core",;'), + # "e" + U+0301 combining acute accent, not the precomposed "é" (U+00E9). + ("unicode_combining", "vse\u0301core"), + ("cjk", "向量索引"), + ("emoji", "🔍index"), + ] +] + +# Property [index Nonexistent Skips Dimension Check]: a nonexistent index name +# combined with a dimension-mismatched queryVector still returns zero results with +# no error, because the dimension check is skipped when the named index is absent. +VECTORSEARCH_INDEX_DIMENSION_SKIP_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + "nonexistent_skips_dimension_check", + raw_res=True, + pipeline=[ + { + "$vectorSearch": { + "index": "no_such_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + } + }, + ], + expected={"cursor.firstBatch": Len(0)}, + msg="$vectorSearch should return zero results without a dimension error when " + "the named index does not exist", + ), +] + +# Property [path Field Resolution]: a path naming the correct vector-indexed +# field resolves and returns the collection's similarity-ordered documents, +# including a dot-notation path resolved against a nested-path index. +VECTORSEARCH_PATH_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + f"path_{tid}", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": path, + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + } + }, + ], + expected=PerDoc( + {"_id": Eq(1)}, {"_id": Eq(2)}, {"_id": Eq(3)}, {"_id": Eq(4)}, {"_id": Eq(5)} + ), + msg=f"$vectorSearch should resolve the {tid} vector path and return results", + ) + for tid, path in [ + ("top_level", "ve"), + ("dot_notation_nested", "meta.vec"), + ] +] + +VECTORSEARCH_INDEX_PATH_ALL_TESTS = ( + VECTORSEARCH_INDEX_MATCH_TESTS + + VECTORSEARCH_INDEX_SILENT_MISS_TESTS + + VECTORSEARCH_INDEX_DIMENSION_SKIP_TESTS + + VECTORSEARCH_PATH_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(VECTORSEARCH_INDEX_PATH_ALL_TESTS)) +def test_vectorSearch_index_path(test_case: VectorSearchTest, engine_client, request): + """$vectorSearch: index and path resolution.""" + coll = request.getfixturevalue(test_case.collection_fixture) + result = execute_command( + coll, + {"aggregate": coll.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + raw_res=test_case.raw_res, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_index_path_errors.py b/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_index_path_errors.py new file mode 100644 index 000000000..09a5abb6d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_index_path_errors.py @@ -0,0 +1,283 @@ +"""Tests for the $vectorSearch stage: index and path errors.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Code, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) +from bson.binary import Binary + +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + UNKNOWN_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_ONE_AND_HALF, + DOUBLE_ZERO, +) + +from .utils.vectorSearch_common import ( + VectorSearchTest, +) + +pytestmark = pytest.mark.requires(search=True) + +# Property [index Type Strictness]: a non-string index value of any BSON type is +# rejected as a non-string with no coercion. +VECTORSEARCH_INDEX_TYPE_ERROR_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + f"index_type_{tid}", + collection_fixture="vector_search_no_index_collection", + pipeline=[ + { + "$vectorSearch": { + "index": val, + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + } + } + ], + error_code=UNKNOWN_ERROR, + msg=f"$vectorSearch should reject a {tid} index value as a non-string", + ) + for tid, val in [ + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool", True), + ("array", ["a"]), + ("object", {"a": 1}), + ("objectid", ObjectId("5a9427648b0beebeb69537a5")), + ("datetime", datetime(2020, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [index Empty String]: an empty-string index is rejected as empty, +# distinct from both the non-string type error and the nonexistent-name silent +# miss. +VECTORSEARCH_INDEX_EMPTY_ERROR_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + "index_empty_string", + collection_fixture="vector_search_no_index_collection", + pipeline=[ + { + "$vectorSearch": { + "index": "", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$vectorSearch should reject an empty-string index", + ), +] + +# Property [path Type Strictness]: a non-string path value of any BSON type is +# rejected as a non-string with no coercion. +VECTORSEARCH_PATH_TYPE_ERROR_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + f"path_type_{tid}", + collection_fixture="vector_search_no_index_collection", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": val, + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + } + } + ], + error_code=UNKNOWN_ERROR, + msg=f"$vectorSearch should reject a {tid} path value as a non-string", + ) + for tid, val in [ + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool", True), + ("array", ["a"]), + ("object", {"a": 1}), + ("objectid", ObjectId("5a9427648b0beebeb69537a5")), + ("datetime", datetime(2020, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [path Not Indexed As Vector]: a path that does not name a +# vector-indexed field is a hard error, whether the field is nonexistent, an +# existing non-vector field, or a field indexed only as the filter type. +VECTORSEARCH_PATH_NOT_INDEXED_ERROR_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + f"path_not_indexed_{tid}", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": path, + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + } + } + ], + error_code=UNKNOWN_ERROR, + msg=f"$vectorSearch should reject a {tid} path as not indexed as vector", + ) + for tid, path in [ + ("nonexistent_field", "no_such_field"), + ("non_vector_field", "name"), + ("filter_only_field", "cat"), + ] +] + +# Property [path No Field-Path Syntax Validation]: a malformed field-path string +# is looked up literally and produces the not-indexed-as-vector error, never a +# field-path syntax error. +VECTORSEARCH_PATH_SYNTAX_ERROR_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + f"path_syntax_{tid}", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": path, + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + } + } + ], + error_code=UNKNOWN_ERROR, + msg=f"$vectorSearch should look up a {tid} path literally and reject it as " + "not indexed as vector", + ) + for tid, path in [ + ("empty", ""), + ("leading_dot", ".x"), + ("trailing_dot", "x."), + ("empty_component", "a..b"), + ("null_byte", "a\x00b"), + ("deep_nesting", ".".join("a" * 50)), + ("very_long", "a" * 10_000), + ] +] + +# Property [path Literal Not Expression]: a dollar-prefixed or variable-like path +# string is matched literally with no expression or variable evaluation, yielding +# the not-indexed-as-vector error. +VECTORSEARCH_PATH_LITERAL_ERROR_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + f"path_literal_{tid}", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": path, + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + } + } + ], + error_code=UNKNOWN_ERROR, + msg=f"$vectorSearch should treat a {tid} path as a literal name, not an " + "expression, and reject it as not indexed as vector", + ) + for tid, path in [ + ("field_ref", "$embedding"), + ("now_variable", "$$NOW"), + ("root_variable", "$$ROOT"), + ("dollar_only", "$"), + ("double_dollar", "$$"), + ] +] + +# Property [path Exact Byte Matching]: path matching is byte-for-byte, so a +# case variant or whitespace-padded form of the real field name does not match +# and yields the not-indexed-as-vector error. +VECTORSEARCH_PATH_EXACT_MATCH_ERROR_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + f"path_exact_{tid}", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": path, + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + } + } + ], + error_code=UNKNOWN_ERROR, + msg=f"$vectorSearch should not match a {tid} of the real field name and " + "reject it as not indexed as vector", + ) + for tid, path in [ + ("case_variant", "vc".upper()), + ("leading_space", " vc"), + ("trailing_space", "vc "), + # "cafe" + combining acute (U+0301): the decomposed (NFD) form of the + # indexed precomposed "caf\u00e9_vec" (U+00E9), a distinct byte sequence + # that must not match. + ("non_normalized_unicode", "cafe\u0301_vec"), + ] +] + +VECTORSEARCH_INDEX_PATH_ERRORS_ALL_TESTS = ( + VECTORSEARCH_INDEX_TYPE_ERROR_TESTS + + VECTORSEARCH_INDEX_EMPTY_ERROR_TESTS + + VECTORSEARCH_PATH_TYPE_ERROR_TESTS + + VECTORSEARCH_PATH_NOT_INDEXED_ERROR_TESTS + + VECTORSEARCH_PATH_SYNTAX_ERROR_TESTS + + VECTORSEARCH_PATH_LITERAL_ERROR_TESTS + + VECTORSEARCH_PATH_EXACT_MATCH_ERROR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(VECTORSEARCH_INDEX_PATH_ERRORS_ALL_TESTS)) +def test_vectorSearch_index_path_errors(test_case: VectorSearchTest, engine_client, request): + """$vectorSearch: index and path errors.""" + coll = request.getfixturevalue(test_case.collection_fixture) + result = execute_command( + coll, + {"aggregate": coll.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_limit.py b/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_limit.py new file mode 100644 index 000000000..fffe48977 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_limit.py @@ -0,0 +1,285 @@ +"""Tests for the $vectorSearch stage: limit acceptance and errors.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Code, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) +from bson.binary import Binary + +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + UNKNOWN_ERROR, + VECTOR_SEARCH_LIMIT_NOT_NUMBER_ERROR, + VECTOR_SEARCH_LIMIT_NOT_POSITIVE_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import ( + Eq, + PerDoc, +) +from documentdb_tests.framework.test_constants import ( + DOUBLE_NEGATIVE_ZERO, + DOUBLE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + INT32_OVERFLOW, +) + +from .utils.vectorSearch_common import ( + VectorSearchTest, +) + +pytestmark = pytest.mark.requires(search=True) + +# Property [limit Numeric Type Acceptance]: limit accepts an int32, Int64, or +# whole-number double and is treated as the integer value, returning exactly that +# many top-similarity documents. +VECTORSEARCH_LIMIT_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + f"limit_{tid}", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": limit, + } + }, + ], + expected=expected, + msg=f"$vectorSearch should accept a {tid} limit and return the integer " + "number of top-similarity documents", + ) + for tid, limit, expected in [ + ("int32", 2, PerDoc({"_id": Eq(1)}, {"_id": Eq(2)})), + ("int64", Int64(3), PerDoc({"_id": Eq(1)}, {"_id": Eq(2)}, {"_id": Eq(3)})), + ("whole_double", 3.0, PerDoc({"_id": Eq(1)}, {"_id": Eq(2)}, {"_id": Eq(3)})), + ] +] + +# Property [limit Type Strictness]: a non-number limit value of any BSON type is +# rejected at the parse layer as not a number, with no coercion. +VECTORSEARCH_LIMIT_TYPE_ERROR_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + f"limit_type_{tid}", + collection_fixture="vector_search_no_index_collection", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": val, + } + } + ], + error_code=VECTOR_SEARCH_LIMIT_NOT_NUMBER_ERROR, + msg=f"$vectorSearch should reject a {tid} limit value as a non-number", + ) + for tid, val in [ + ("bool_true", True), + ("bool_false", False), + ("string", "5"), + ("array", [5]), + ("object", {"a": 5}), + ("objectid", ObjectId("5a9427648b0beebeb69537a5")), + ("datetime", datetime(2020, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [limit Literal Not Expression]: a limit given as an expression object +# is rejected at the BSON-type layer as a non-number, with no expression +# evaluation. +VECTORSEARCH_LIMIT_LITERAL_ERROR_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + "limit_literal_object", + collection_fixture="vector_search_no_index_collection", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": {"$literal": 3}, + } + } + ], + error_code=VECTOR_SEARCH_LIMIT_NOT_NUMBER_ERROR, + msg="$vectorSearch should reject an expression-object limit without evaluating it", + ), +] + +# Property [limit Fractional Rejection]: a double limit whose truncation is at +# least one but is not a whole number is rejected as a non-integer. +VECTORSEARCH_LIMIT_FRACTIONAL_ERROR_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + "limit_fractional_one_point_five", + collection_fixture="vector_search_no_index_collection", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 1.5, + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$vectorSearch should reject a fractional double limit", + ), +] + +# Property [limit Decimal128 Rejection]: a Decimal128 limit is rejected as a +# non-integer even when its value is whole, unlike a whole-number double. +VECTORSEARCH_LIMIT_DECIMAL_ERROR_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + "limit_decimal_whole", + collection_fixture="vector_search_no_index_collection", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": Decimal128("3"), + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$vectorSearch should reject a whole-valued Decimal128 limit", + ), +] + +# Property [limit Positivity]: a limit whose truncation is less than one is +# rejected as non-positive, checked before the integer-ness check. +VECTORSEARCH_LIMIT_NOT_POSITIVE_ERROR_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + f"limit_not_positive_{tid}", + collection_fixture="vector_search_no_index_collection", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": val, + } + } + ], + error_code=VECTOR_SEARCH_LIMIT_NOT_POSITIVE_ERROR, + msg=f"$vectorSearch should reject a non-positive limit ({tid})", + ) + for tid, val in [ + ("zero_int32", 0), + ("zero_double", DOUBLE_ZERO), + ("negative_zero_double", DOUBLE_NEGATIVE_ZERO), + ("fractional_half", 0.5), + ("negative_int", -1), + ("negative_double", -1.5), + ("nan", FLOAT_NAN), + ("negative_infinity", FLOAT_NEGATIVE_INFINITY), + ] +] + +# Property [limit Int32 Overflow]: a positive whole limit that does not fit in a +# 32-bit integer is rejected as overflowing. +VECTORSEARCH_LIMIT_OVERFLOW_ERROR_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + f"limit_overflow_{tid}", + collection_fixture="vector_search_no_index_collection", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": val, + } + } + ], + error_code=UNKNOWN_ERROR, + msg=f"$vectorSearch should reject a limit that exceeds int32 range ({tid})", + ) + for tid, val in [ + ("int64_just_over", Int64(INT32_OVERFLOW)), + ("double_just_over", float(INT32_OVERFLOW)), + ("positive_infinity", FLOAT_INFINITY), + ] +] + +# Property [limit ENN Ceiling]: under ENN (exact true, no numCandidates) a limit +# at or above the exact-search ceiling is rejected during ENN execution. +VECTORSEARCH_LIMIT_ENN_CEILING_ERROR_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + "limit_enn_ceiling", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "exact": True, + "limit": 2_147_483_631, + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$vectorSearch should reject an ENN limit at the exact-search ceiling", + ), +] + +VECTORSEARCH_LIMIT_ALL_TESTS = ( + VECTORSEARCH_LIMIT_TESTS + + VECTORSEARCH_LIMIT_TYPE_ERROR_TESTS + + VECTORSEARCH_LIMIT_LITERAL_ERROR_TESTS + + VECTORSEARCH_LIMIT_FRACTIONAL_ERROR_TESTS + + VECTORSEARCH_LIMIT_DECIMAL_ERROR_TESTS + + VECTORSEARCH_LIMIT_NOT_POSITIVE_ERROR_TESTS + + VECTORSEARCH_LIMIT_OVERFLOW_ERROR_TESTS + + VECTORSEARCH_LIMIT_ENN_CEILING_ERROR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(VECTORSEARCH_LIMIT_ALL_TESTS)) +def test_vectorSearch_limit(test_case: VectorSearchTest, engine_client, request): + """$vectorSearch: limit acceptance and errors.""" + coll = request.getfixturevalue(test_case.collection_fixture) + result = execute_command( + coll, + {"aggregate": coll.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_nested.py b/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_nested.py new file mode 100644 index 000000000..f8f55ce37 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_nested.py @@ -0,0 +1,551 @@ +"""Tests for the $vectorSearch stage: nestedRoot scoping and nestedOptions.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Code, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) +from bson.binary import Binary + +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + UNKNOWN_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import ( + Contains, + Len, +) +from documentdb_tests.framework.test_constants import ( + DECIMAL128_ONE_AND_HALF, + DOUBLE_ZERO, +) + +from .utils.vectorSearch_common import ( + VectorSearchTest, + wait_for_search_index_ready, +) + +pytestmark = pytest.mark.requires(search=True) + +_NESTED_CORPUS = [ + { + "_id": 1, + "country": "US", + "reviews": [ + {"rating": 5, "embedding": [1.0, DOUBLE_ZERO, DOUBLE_ZERO]}, + {"rating": 3, "embedding": [0.9, 0.1, DOUBLE_ZERO]}, + ], + }, + { + "_id": 2, + "country": "US", + "reviews": [{"rating": 2, "embedding": [DOUBLE_ZERO, 1.0, DOUBLE_ZERO]}], + }, + { + "_id": 3, + "country": "CA", + "reviews": [{"rating": 5, "embedding": [0.8, 0.2, DOUBLE_ZERO]}], + }, +] + + +@pytest.fixture(scope="module") +def nested_vector_search_collection(engine_client, worker_id): + """Provide a collection with a READY nestedRoot vectorSearch index over a fixed corpus.""" + db_name = f"vs_nested_{worker_id}" + db = engine_client[db_name] + coll = db["nested_vectors"] + db.drop_collection(coll.name) + db.create_collection(coll.name) + coll.insert_many([dict(doc) for doc in _NESTED_CORPUS]) + db.command( + { + "createSearchIndexes": coll.name, + "indexes": [ + { + "name": "vs_nested_index", + "type": "vectorSearch", + "definition": { + "nestedRoot": "reviews", + "fields": [ + { + "type": "vector", + "path": "reviews.embedding", + "numDimensions": 3, + "similarity": "cosine", + }, + {"type": "filter", "path": "reviews.rating"}, + {"type": "filter", "path": "country"}, + ], + }, + } + ], + } + ) + wait_for_search_index_ready(coll) + yield coll + engine_client.drop_database(db_name) + + +# Property [parentFilter Nested Root Scoping]: on a nestedRoot index parentFilter +# pre-filters root-level fields while filter pre-filters nested-level fields, the +# two AND-combine, and a predicate referencing the other level returns zero results. +VECTORSEARCH_PARENT_FILTER_NESTED_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + "nested_parent_filter_root_field", + collection_fixture="nested_vector_search_collection", + raw_res=True, + pipeline=[ + { + "$vectorSearch": { + "index": "vs_nested_index", + "path": "reviews.embedding", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 20, + "limit": 5, + "parentFilter": {"country": "US"}, + } + }, + ], + expected={"cursor.firstBatch": [Len(2), Contains("_id", 1), Contains("_id", 2)]}, + msg="$vectorSearch parentFilter should pre-filter root-level fields on a nestedRoot index", + ), + VectorSearchTest( + "nested_filter_nested_field", + collection_fixture="nested_vector_search_collection", + raw_res=True, + pipeline=[ + { + "$vectorSearch": { + "index": "vs_nested_index", + "path": "reviews.embedding", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 20, + "limit": 5, + "filter": {"reviews.rating": 5}, + } + }, + ], + expected={"cursor.firstBatch": [Len(2), Contains("_id", 1), Contains("_id", 3)]}, + msg="$vectorSearch filter should pre-filter nested-level fields on a nestedRoot index", + ), + VectorSearchTest( + "nested_filter_and_parent_filter", + collection_fixture="nested_vector_search_collection", + raw_res=True, + pipeline=[ + { + "$vectorSearch": { + "index": "vs_nested_index", + "path": "reviews.embedding", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 20, + "limit": 5, + "filter": {"reviews.rating": 5}, + "parentFilter": {"country": "US"}, + } + }, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 1)]}, + msg="$vectorSearch should AND-combine nested filter with root parentFilter", + ), + VectorSearchTest( + "nested_parent_filter_on_nested_field", + collection_fixture="nested_vector_search_collection", + raw_res=True, + pipeline=[ + { + "$vectorSearch": { + "index": "vs_nested_index", + "path": "reviews.embedding", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 20, + "limit": 5, + "parentFilter": {"reviews.rating": 5}, + } + }, + ], + expected={"cursor.firstBatch": Len(0)}, + msg="$vectorSearch parentFilter on a nested field should return zero results", + ), + VectorSearchTest( + "nested_filter_on_root_field", + collection_fixture="nested_vector_search_collection", + raw_res=True, + pipeline=[ + { + "$vectorSearch": { + "index": "vs_nested_index", + "path": "reviews.embedding", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 20, + "limit": 5, + "filter": {"country": "US"}, + } + }, + ], + expected={"cursor.firstBatch": Len(0)}, + msg="$vectorSearch filter on a root field should return zero results", + ), +] + +# Property [nestedOptions scoreMode]: on a nestedRoot index nestedOptions.scoreMode +# combines a parent's matching nested-array child scores, with "avg" averaging the +# child scores and "max" taking the maximum. +VECTORSEARCH_NESTED_OPTIONS_SCORE_MODE_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + "score_mode_avg", + collection_fixture="nested_vector_search_collection", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_nested_index", + "path": "reviews.embedding", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 20, + "limit": 5, + "nestedOptions": {"scoreMode": "avg"}, + } + }, + {"$sort": {"_id": 1}}, + {"$project": {"_id": 1, "score": {"$meta": "vectorSearchScore"}}}, + ], + expected=[ + {"_id": 1, "score": pytest.approx(0.9984709024429321)}, + {"_id": 2, "score": pytest.approx(0.5)}, + {"_id": 3, "score": pytest.approx(0.9850712418556213)}, + ], + msg="$vectorSearch should average a parent's matching child scores for scoreMode avg", + ), + VectorSearchTest( + "score_mode_max", + collection_fixture="nested_vector_search_collection", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_nested_index", + "path": "reviews.embedding", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 20, + "limit": 5, + "nestedOptions": {"scoreMode": "max"}, + } + }, + {"$sort": {"_id": 1}}, + {"$project": {"_id": 1, "score": {"$meta": "vectorSearchScore"}}}, + ], + expected=[ + {"_id": 1, "score": pytest.approx(1.0)}, + {"_id": 2, "score": pytest.approx(0.5)}, + {"_id": 3, "score": pytest.approx(0.9850712418556213)}, + ], + msg="$vectorSearch should take the maximum of a parent's matching child " + "scores for scoreMode max", + ), +] + +# Property [nestedOptions Default Max]: omitting nestedOptions, or providing an +# empty nestedOptions document, yields the same parent score as scoreMode "max". +VECTORSEARCH_NESTED_OPTIONS_DEFAULT_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + "default_max_omitted", + collection_fixture="nested_vector_search_collection", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_nested_index", + "path": "reviews.embedding", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 20, + "limit": 5, + } + }, + {"$sort": {"_id": 1}}, + {"$project": {"_id": 1, "score": {"$meta": "vectorSearchScore"}}}, + ], + expected=[ + {"_id": 1, "score": pytest.approx(1.0)}, + {"_id": 2, "score": pytest.approx(0.5)}, + {"_id": 3, "score": pytest.approx(0.9850712418556213)}, + ], + msg="$vectorSearch should default to scoreMode max when nestedOptions is omitted", + ), + VectorSearchTest( + "default_max_empty_document", + collection_fixture="nested_vector_search_collection", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_nested_index", + "path": "reviews.embedding", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 20, + "limit": 5, + "nestedOptions": {}, + } + }, + {"$sort": {"_id": 1}}, + {"$project": {"_id": 1, "score": {"$meta": "vectorSearchScore"}}}, + ], + expected=[ + {"_id": 1, "score": pytest.approx(1.0)}, + {"_id": 2, "score": pytest.approx(0.5)}, + {"_id": 3, "score": pytest.approx(0.9850712418556213)}, + ], + msg="$vectorSearch should default to scoreMode max for an empty nestedOptions document", + ), + VectorSearchTest( + "default_max_score_mode_null", + collection_fixture="nested_vector_search_collection", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_nested_index", + "path": "reviews.embedding", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 20, + "limit": 5, + "nestedOptions": {"scoreMode": None}, + } + }, + {"$sort": {"_id": 1}}, + {"$project": {"_id": 1, "score": {"$meta": "vectorSearchScore"}}}, + ], + expected=[ + {"_id": 1, "score": pytest.approx(1.0)}, + {"_id": 2, "score": pytest.approx(0.5)}, + {"_id": 3, "score": pytest.approx(0.9850712418556213)}, + ], + msg="$vectorSearch should treat a null scoreMode as absent and default to scoreMode max", + ), +] + +# Property [nestedOptions Non-Object Rejection]: a nestedOptions value that is +# not a document is rejected as not a document, with no type coercion. +VECTORSEARCH_NESTED_OPTIONS_TYPE_ERROR_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + f"nested_options_type_{tid}", + collection_fixture="vector_search_no_index_collection", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + "nestedOptions": val, + } + } + ], + error_code=UNKNOWN_ERROR, + msg=f"$vectorSearch should reject a {tid} nestedOptions value as not a document", + ) + for tid, val in [ + ("string", "x"), + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool", True), + ("array", []), + ("objectid", ObjectId("5a9427648b0beebeb69537a5")), + ("datetime", datetime(2020, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [nestedOptions Null As Omitted]: nestedOptions null is treated as +# field-absent and the query succeeds on a flat index as if nestedOptions were +# omitted, in contrast to an empty nestedOptions document, which is rejected on a +# flat index for lacking a nested root. +VECTORSEARCH_NESTED_OPTIONS_NULL_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + "nested_options_null_omitted_flat_index", + raw_res=True, + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + "nestedOptions": None, + } + }, + ], + expected={ + "cursor.firstBatch": [ + Len(5), + Contains("_id", 1), + Contains("_id", 2), + Contains("_id", 3), + Contains("_id", 4), + Contains("_id", 5), + ] + }, + msg="$vectorSearch should treat nestedOptions null as omitted and succeed on a " + "flat index", + ), +] + +# Property [nestedOptions Requires Nested Root]: nestedOptions on a flat +# (non-nestedRoot) index is rejected because the index has no nested root for a +# nested-array score mode to apply to. +VECTORSEARCH_NESTED_OPTIONS_FLAT_INDEX_ERROR_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + "nested_options_on_flat_index", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + "nestedOptions": {"scoreMode": "avg"}, + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$vectorSearch should reject nestedOptions on a flat index lacking a nested root", + ), +] + +# Property [nestedOptions Invalid scoreMode]: a scoreMode outside the accepted +# set is rejected, and the accepted values are matched case-sensitively. +VECTORSEARCH_NESTED_OPTIONS_SCORE_MODE_ERROR_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + f"nested_options_score_mode_{tid}", + collection_fixture="nested_vector_search_collection", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_nested_index", + "path": "reviews.embedding", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + "nestedOptions": {"scoreMode": score_mode}, + } + } + ], + error_code=UNKNOWN_ERROR, + msg=f"$vectorSearch should reject the {tid} scoreMode as unsupported", + ) + for tid, score_mode in [ + ("unrecognized", "median"), + ("case_variant", "AVG"), + ] +] + +# Property [nestedOptions scoreMode Type Strictness]: a non-string scoreMode value +# of any BSON type is rejected as not a string with no coercion. +VECTORSEARCH_NESTED_OPTIONS_SCORE_MODE_TYPE_ERROR_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + f"nested_options_score_mode_type_{tid}", + collection_fixture="nested_vector_search_collection", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_nested_index", + "path": "reviews.embedding", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + "nestedOptions": {"scoreMode": val}, + } + } + ], + error_code=UNKNOWN_ERROR, + msg=f"$vectorSearch should reject a {tid} scoreMode value as a non-string", + ) + for tid, val in [ + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool", True), + ("array", ["max"]), + ("object", {"a": 1}), + ("objectid", ObjectId("5a9427648b0beebeb69537a5")), + ("datetime", datetime(2020, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [nestedOptions Unknown Sub-Field Rejection]: an unrecognized sub-field +# of nestedOptions is rejected, unlike unknown top-level spec fields which are +# silently ignored. +VECTORSEARCH_NESTED_OPTIONS_UNKNOWN_SUBFIELD_ERROR_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + "nested_options_unknown_subfield", + collection_fixture="nested_vector_search_collection", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_nested_index", + "path": "reviews.embedding", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + "nestedOptions": {"bogus": 1}, + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$vectorSearch should reject an unrecognized nestedOptions sub-field", + ), +] + +VECTORSEARCH_NESTED_ALL_TESTS = ( + VECTORSEARCH_PARENT_FILTER_NESTED_TESTS + + VECTORSEARCH_NESTED_OPTIONS_SCORE_MODE_TESTS + + VECTORSEARCH_NESTED_OPTIONS_DEFAULT_TESTS + + VECTORSEARCH_NESTED_OPTIONS_TYPE_ERROR_TESTS + + VECTORSEARCH_NESTED_OPTIONS_NULL_TESTS + + VECTORSEARCH_NESTED_OPTIONS_FLAT_INDEX_ERROR_TESTS + + VECTORSEARCH_NESTED_OPTIONS_SCORE_MODE_ERROR_TESTS + + VECTORSEARCH_NESTED_OPTIONS_SCORE_MODE_TYPE_ERROR_TESTS + + VECTORSEARCH_NESTED_OPTIONS_UNKNOWN_SUBFIELD_ERROR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(VECTORSEARCH_NESTED_ALL_TESTS)) +def test_vectorSearch_nested(test_case: VectorSearchTest, engine_client, request): + """$vectorSearch: nestedRoot scoping and nestedOptions.""" + coll = request.getfixturevalue(test_case.collection_fixture) + result = execute_command( + coll, + {"aggregate": coll.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + raw_res=test_case.raw_res, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_num_candidates.py b/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_num_candidates.py new file mode 100644 index 000000000..6fb8572d0 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_num_candidates.py @@ -0,0 +1,325 @@ +"""Tests for the $vectorSearch stage: numCandidates acceptance and errors.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Code, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) +from bson.binary import Binary + +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + UNKNOWN_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import ( + Eq, + PerDoc, +) +from documentdb_tests.framework.test_constants import ( + DOUBLE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + INT32_MAX, + INT32_OVERFLOW, +) + +from .utils.vectorSearch_common import ( + VectorSearchTest, +) + +pytestmark = pytest.mark.requires(search=True) + +# Property [numCandidates Numeric Type and Range Acceptance]: numCandidates +# accepts an int32, Int64, or whole-number double, and both range bounds are +# accepted. +VECTORSEARCH_NUM_CANDIDATES_RANGE_TESTS: list[VectorSearchTest] = [ + *[ + VectorSearchTest( + f"range_{tid}", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": nc, + "limit": 3, + } + }, + ], + expected=PerDoc({"_id": Eq(1)}, {"_id": Eq(2)}, {"_id": Eq(3)}), + msg=f"$vectorSearch should accept a {tid} numCandidates within range", + ) + for tid, nc in [ + ("int32", 5), + ("int64", Int64(5)), + ("whole_double", 5.0), + ] + ], + VectorSearchTest( + "range_lower_boundary_one", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 1, + "limit": 1, + } + }, + ], + expected=PerDoc({"_id": Eq(1)}), + msg="$vectorSearch should accept the lower numCandidates bound of 1", + ), + VectorSearchTest( + "range_upper_boundary_ten_thousand", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10_000, + "limit": 5, + } + }, + ], + expected=PerDoc( + {"_id": Eq(1)}, {"_id": Eq(2)}, {"_id": Eq(3)}, {"_id": Eq(4)}, {"_id": Eq(5)} + ), + msg="$vectorSearch should accept the upper numCandidates bound of 10000", + ), +] + +# Property [numCandidates Non-Integer Double]: a double numCandidates whose +# value is not a whole number, including NaN, is rejected as not an integer. +VECTORSEARCH_NUM_CANDIDATES_DOUBLE_NON_INTEGER_ERROR_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + f"num_candidates_double_non_integer_{tid}", + collection_fixture="vector_search_no_index_collection", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": val, + "limit": 5, + } + } + ], + error_code=UNKNOWN_ERROR, + msg=f"$vectorSearch should reject a {tid} double numCandidates as not an integer", + ) + for tid, val in [ + ("fractional", 10.5), + ("nan", FLOAT_NAN), + ] +] + +# Property [numCandidates Type Strictness]: a numCandidates of any non-integer +# BSON type other than double is rejected as not an integer with no coercion. +VECTORSEARCH_NUM_CANDIDATES_TYPE_ERROR_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + f"num_candidates_type_{tid}", + collection_fixture="vector_search_no_index_collection", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": val, + "limit": 5, + } + } + ], + error_code=UNKNOWN_ERROR, + msg=f"$vectorSearch should reject a {tid} numCandidates as not an integer", + ) + for tid, val in [ + ("decimal128_whole", Decimal128("3")), + ("bool", True), + ("string", "5"), + ("array", [5]), + ("object", {"a": 5}), + ("objectid", ObjectId("5a9427648b0beebeb69537a5")), + ("datetime", datetime(2020, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [numCandidates Literal Not Expression]: a numCandidates given as an +# expression object is rejected at the BSON-type layer as not an integer, with no +# expression evaluation. +VECTORSEARCH_NUM_CANDIDATES_LITERAL_ERROR_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + "num_candidates_literal_object", + collection_fixture="vector_search_no_index_collection", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": {"$literal": 10}, + "limit": 5, + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$vectorSearch should reject an expression-object numCandidates without evaluating it", + ), +] + +# Property [numCandidates Bounds]: an integer-valued numCandidates outside the +# accepted range is rejected as out of bounds, with integer-valued doubles routed +# to the bounds check rather than the type check. +VECTORSEARCH_NUM_CANDIDATES_BOUNDS_ERROR_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + f"num_candidates_bounds_{tid}", + collection_fixture="vector_search_no_index_collection", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": val, + "limit": 1, + } + } + ], + error_code=UNKNOWN_ERROR, + msg=f"$vectorSearch should reject a {tid} numCandidates as out of bounds", + ) + for tid, val in [ + ("lower_zero", 0), + ("negative_whole_double", -5.0), + ("upper_10001", 10_001), + ("int32_max", INT32_MAX), + ] +] + +# Property [numCandidates Int32 Range]: a numCandidates that does not fit in a +# signed 32-bit integer is rejected as overflowing above the range or +# underflowing below it. +VECTORSEARCH_NUM_CANDIDATES_INT32_RANGE_ERROR_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + f"num_candidates_int32_range_{tid}", + collection_fixture="vector_search_no_index_collection", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": val, + "limit": 5, + } + } + ], + error_code=UNKNOWN_ERROR, + msg=f"$vectorSearch should reject a {tid} numCandidates as outside int32 range", + ) + for tid, val in [ + ("int64_just_over", Int64(INT32_OVERFLOW)), + ("double_just_over", float(INT32_OVERFLOW)), + ("positive_infinity", FLOAT_INFINITY), + ("negative_infinity", FLOAT_NEGATIVE_INFINITY), + ] +] + +# Property [numCandidates Less Than Limit]: under ANN a numCandidates smaller +# than limit is rejected because numCandidates must be greater than or equal to +# limit. +VECTORSEARCH_NUM_CANDIDATES_LESS_THAN_LIMIT_ERROR_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + "num_candidates_less_than_limit", + collection_fixture="vector_search_no_index_collection", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 4, + "limit": 5, + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$vectorSearch should reject a numCandidates smaller than limit under ANN", + ), +] + +# Property [numCandidates Forbidden With Exact]: a non-null numCandidates is +# rejected when exact is true, because ENN forbids numCandidates rather than +# merely ignoring it. +VECTORSEARCH_NUM_CANDIDATES_EXACT_FORBIDDEN_ERROR_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + "num_candidates_forbidden_with_exact", + collection_fixture="vector_search_no_index_collection", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "exact": True, + "numCandidates": 10, + "limit": 5, + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$vectorSearch should reject a non-null numCandidates when exact is true", + ), +] + +VECTORSEARCH_NUM_CANDIDATES_ALL_TESTS = ( + VECTORSEARCH_NUM_CANDIDATES_RANGE_TESTS + + VECTORSEARCH_NUM_CANDIDATES_DOUBLE_NON_INTEGER_ERROR_TESTS + + VECTORSEARCH_NUM_CANDIDATES_TYPE_ERROR_TESTS + + VECTORSEARCH_NUM_CANDIDATES_LITERAL_ERROR_TESTS + + VECTORSEARCH_NUM_CANDIDATES_BOUNDS_ERROR_TESTS + + VECTORSEARCH_NUM_CANDIDATES_INT32_RANGE_ERROR_TESTS + + VECTORSEARCH_NUM_CANDIDATES_LESS_THAN_LIMIT_ERROR_TESTS + + VECTORSEARCH_NUM_CANDIDATES_EXACT_FORBIDDEN_ERROR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(VECTORSEARCH_NUM_CANDIDATES_ALL_TESTS)) +def test_vectorSearch_num_candidates(test_case: VectorSearchTest, engine_client, request): + """$vectorSearch: numCandidates acceptance and errors.""" + coll = request.getfixturevalue(test_case.collection_fixture) + result = execute_command( + coll, + {"aggregate": coll.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_parent_filter.py b/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_parent_filter.py new file mode 100644 index 000000000..6b4502faf --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_parent_filter.py @@ -0,0 +1,369 @@ +"""Tests for the $vectorSearch stage: parentFilter pre-filtering and errors.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Code, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) +from bson.binary import Binary + +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + UNKNOWN_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import ( + Contains, + Len, +) +from documentdb_tests.framework.test_constants import ( + DECIMAL128_ONE_AND_HALF, + DOUBLE_ZERO, +) + +from .utils.vectorSearch_common import ( + _FILTER_OID_A, + _FILTER_UUID_A, + VectorSearchTest, +) + +pytestmark = pytest.mark.requires(search=True) + +# Property [parentFilter Null As Omitted]: parentFilter null is treated as +# field-absent and the query succeeds as if parentFilter were not specified, +# diverging from filter where null errors. +VECTORSEARCH_PARENT_FILTER_NULL_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + "parent_filter_null_omitted", + raw_res=True, + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + "parentFilter": None, + } + }, + ], + expected={ + "cursor.firstBatch": [ + Len(5), + Contains("_id", 1), + Contains("_id", 2), + Contains("_id", 3), + Contains("_id", 4), + Contains("_id", 5), + ] + }, + msg="$vectorSearch should treat parentFilter null as omitted and return all documents", + ), +] + +# Property [parentFilter Flat Index Acceptance]: parentFilter is accepted on a +# flat index as a second pre-filter on filter-type fields, where an empty +# document matches all and a real predicate AND-combines with filter. +VECTORSEARCH_PARENT_FILTER_FLAT_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + "parent_filter_empty_matches_all", + raw_res=True, + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + "parentFilter": {}, + } + }, + ], + expected={ + "cursor.firstBatch": [ + Len(5), + Contains("_id", 1), + Contains("_id", 2), + Contains("_id", 3), + Contains("_id", 4), + Contains("_id", 5), + ] + }, + msg="$vectorSearch should retain every document for an empty parentFilter on a flat index", + ), + VectorSearchTest( + "parent_filter_second_prefilter", + raw_res=True, + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + "parentFilter": {"cat": "x"}, + } + }, + ], + expected={"cursor.firstBatch": [Len(2), Contains("_id", 1), Contains("_id", 2)]}, + msg="$vectorSearch should apply parentFilter as a pre-filter on a flat index", + ), + VectorSearchTest( + "parent_filter_and_combines_with_filter", + raw_res=True, + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + "filter": {"cat": "x"}, + "parentFilter": {"year": {"$gte": 2000}}, + } + }, + ], + expected={"cursor.firstBatch": [Len(1), Contains("_id", 2)]}, + msg="$vectorSearch should AND-combine parentFilter with filter on a flat index", + ), +] + +# Property [parentFilter Operator Parity]: parentFilter supports the same +# per-field operators and $eq shorthand as filter. +VECTORSEARCH_PARENT_FILTER_OPERATOR_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + f"parent_operator_{tid}", + raw_res=True, + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + "parentFilter": flt, + } + }, + ], + expected={"cursor.firstBatch": [Len(len(ids)), *(Contains("_id", i) for i in ids)]}, + msg=f"$vectorSearch should pre-filter parentFilter with the {tid} operator", + ) + for tid, flt, ids in [ + ("shorthand_eq", {"year": 2001}, [3]), + ("eq", {"year": {"$eq": 2010}}, [4, 5]), + ("ne", {"year": {"$ne": 2010}}, [1, 2, 3]), + ("gt", {"year": {"$gt": 2000}}, [3, 4, 5]), + ("gte", {"year": {"$gte": 2001}}, [3, 4, 5]), + ("lt", {"year": {"$lt": 2001}}, [1, 2]), + ("lte", {"year": {"$lte": 2000}}, [1, 2]), + ("in", {"year": {"$in": [1999, 2001]}}, [1, 3]), + ("nin", {"year": {"$nin": [1999, 2001]}}, [2, 4, 5]), + ("exists", {"cat": {"$exists": True}}, [1, 2, 3, 4, 5]), + ("not", {"year": {"$not": {"$gt": 2000}}}, [1, 2]), + ] +] + +# Property [parentFilter Combinator Parity]: parentFilter composes the same +# top-level combinators and implicit multi-field AND as filter. +VECTORSEARCH_PARENT_FILTER_COMBINATOR_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + f"parent_combinator_{tid}", + raw_res=True, + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + "parentFilter": flt, + } + }, + ], + expected={"cursor.firstBatch": [Len(len(ids)), *(Contains("_id", i) for i in ids)]}, + msg=f"$vectorSearch should compose the {tid} combinator in parentFilter", + ) + for tid, flt, ids in [ + ("and", {"$and": [{"cat": "y"}, {"year": {"$gte": 2010}}]}, [4, 5]), + ("or", {"$or": [{"cat": "x"}, {"year": 2001}]}, [1, 2, 3]), + ("nor", {"$nor": [{"cat": "x"}]}, [3, 4, 5]), + ("implicit_and", {"cat": "y", "active": True}, [3, 5]), + ] +] + +# Property [parentFilter Value Type Parity]: parentFilter pre-filters with the +# same predicate value types as filter, including null as a direct value and +# element membership on an array-valued field. +VECTORSEARCH_PARENT_FILTER_VALUE_TYPE_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + f"parent_value_type_{tid}", + raw_res=True, + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + "parentFilter": flt, + } + }, + ], + expected={"cursor.firstBatch": [Len(len(ids)), *(Contains("_id", i) for i in ids)]}, + msg=f"$vectorSearch should pre-filter parentFilter with a {tid} predicate value", + ) + for tid, flt, ids in [ + ("string", {"cat": "x"}, [1, 2]), + ("int32", {"year": 2000}, [2]), + ("int64", {"count": Int64(20)}, [2]), + ("double", {"rating": 4.5}, [1]), + ("boolean", {"active": True}, [1, 3, 5]), + ("object_id", {"oid": _FILTER_OID_A}, [1, 3, 5]), + ("date", {"created": {"$gt": datetime(2023, 1, 1, tzinfo=timezone.utc)}}, [4, 5]), + ("uuid", {"uid": _FILTER_UUID_A}, [1, 3, 5]), + ("null_direct", {"opt": None}, [3]), + ("array_element_membership", {"tags": "x"}, [1, 5]), + ] +] + +# Property [parentFilter Type Rejection]: a non-object parentFilter value of any +# BSON type, including an array, is uniformly rejected on the mongot executor as +# not a document, with no parse-time object check (diverging from filter null). +VECTORSEARCH_PARENT_FILTER_TYPE_ERROR_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + f"parent_filter_type_{tid}", + collection_fixture="vector_search_no_index_collection", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + "parentFilter": val, + } + } + ], + error_code=UNKNOWN_ERROR, + msg=f"$vectorSearch should reject a {tid} parentFilter value as not a document", + ) + for tid, val in [ + ("string", "x"), + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool", True), + ("array", []), + ("objectid", ObjectId("5a9427648b0beebeb69537a5")), + ("datetime", datetime(2020, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [parentFilter MQL Uniformity]: every MQL violation in parentFilter, +# including constructs that produce distinct error codes under filter, surfaces +# uniformly as the executor validation error with no mongod-side parse layer. +VECTORSEARCH_PARENT_FILTER_MQL_ERROR_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + f"parent_mql_{tid}", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + "parentFilter": flt, + } + } + ], + error_code=UNKNOWN_ERROR, + msg=f"$vectorSearch should reject a {tid} parentFilter as an executor error", + ) + for tid, flt in [ + ("geo_near", {"year": {"$near": [0, 0]}}), + ("text", {"$text": {"$search": "x"}}), + ("comment", {"year": {"$comment": "x"}}), + ("regex_op", {"year": {"$regex": "x"}}), + ("unknown_top_level", {"$bad": 1}), + ("dollar_prefixed_key", {"$year": 1}), + ("not_top_level", {"$not": {"year": 1}}), + ("empty_and", {"$and": []}), + ] +] + +# Property [parentFilter Field Not Indexed]: a parentFilter referencing a field +# not indexed as the filter type is a hard error rather than a silent miss. +VECTORSEARCH_PARENT_FILTER_FIELD_NOT_INDEXED_ERROR_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + "parent_filter_field_not_indexed", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + "parentFilter": {"_id": 1}, + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$vectorSearch should reject a parentFilter on a field not indexed as the filter type", + ), +] + +VECTORSEARCH_PARENT_FILTER_ALL_TESTS = ( + VECTORSEARCH_PARENT_FILTER_NULL_TESTS + + VECTORSEARCH_PARENT_FILTER_FLAT_TESTS + + VECTORSEARCH_PARENT_FILTER_OPERATOR_TESTS + + VECTORSEARCH_PARENT_FILTER_COMBINATOR_TESTS + + VECTORSEARCH_PARENT_FILTER_VALUE_TYPE_TESTS + + VECTORSEARCH_PARENT_FILTER_TYPE_ERROR_TESTS + + VECTORSEARCH_PARENT_FILTER_MQL_ERROR_TESTS + + VECTORSEARCH_PARENT_FILTER_FIELD_NOT_INDEXED_ERROR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(VECTORSEARCH_PARENT_FILTER_ALL_TESTS)) +def test_vectorSearch_parent_filter(test_case: VectorSearchTest, engine_client, request): + """$vectorSearch: parentFilter pre-filtering and errors.""" + coll = request.getfixturevalue(test_case.collection_fixture) + result = execute_command( + coll, + {"aggregate": coll.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + raw_res=test_case.raw_res, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_query_vector.py b/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_query_vector.py new file mode 100644 index 000000000..832481d52 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_query_vector.py @@ -0,0 +1,287 @@ +"""Tests for the $vectorSearch stage: queryVector accepted forms.""" + +from __future__ import annotations + +import pytest +from bson import ( + Int64, +) +from bson.binary import Binary, BinaryVectorDtype + +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.property_checks import ( + Contains, + Len, +) +from documentdb_tests.framework.test_constants import ( + DOUBLE_ZERO, + INT64_ZERO, +) + +from .utils.vectorSearch_common import ( + VectorSearchTest, +) + +pytestmark = pytest.mark.requires(search=True) + +# Baseline cosine scores for the shared query vector, used to assert that every +# accepted numeric and float32-BinData query vector form scores identically. +_COSINE_QUERY_SCORES = [ + {"_id": 1, "score": pytest.approx(1.0)}, + {"_id": 2, "score": pytest.approx(0.9850712418556213)}, + {"_id": 3, "score": pytest.approx(0.9160251617431641)}, + {"_id": 4, "score": pytest.approx(0.6212677955627441)}, + {"_id": 5, "score": pytest.approx(0.5)}, +] + +# Property [queryVector Numeric And Float32 Equivalence]: every accepted numeric +# array form and the equivalent float32 BinData query vector yield scores +# identical to the double-array baseline. +VECTORSEARCH_QUERY_VECTOR_EQUIVALENCE_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + f"qv_equivalence_{tid}", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": qv, + "numCandidates": 10, + "limit": 5, + } + }, + {"$project": {"_id": 1, "score": {"$meta": "vectorSearchScore"}}}, + ], + expected=_COSINE_QUERY_SCORES, + msg=f"$vectorSearch should accept a {tid} queryVector and score it " + "identically to the double array", + ) + for tid, qv in [ + ("int32_array", [1, 0, 0]), + ("int64_array", [Int64(1), INT64_ZERO, INT64_ZERO]), + ("mixed_array", [1, DOUBLE_ZERO, 0]), + ( + "float32_bindata", + Binary.from_vector([1.0, DOUBLE_ZERO, DOUBLE_ZERO], BinaryVectorDtype.FLOAT32), + ), + ] +] + +# Property [queryVector Signed Components]: a query vector containing negative +# components is accepted by cosine, euclidean, and dotProduct indexes. +VECTORSEARCH_QUERY_VECTOR_SIGN_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + f"qv_mixed_sign_{tid}", + raw_res=True, + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": path, + "queryVector": [0.5, -0.5, 0.5], + "numCandidates": 10, + "limit": 5, + } + }, + ], + expected={ + "cursor.firstBatch": [ + Len(5), + Contains("_id", 1), + Contains("_id", 2), + Contains("_id", 3), + Contains("_id", 4), + Contains("_id", 5), + ] + }, + msg=f"$vectorSearch should accept a mixed-sign queryVector on a {tid} index", + ) + for tid, path in [ + ("cosine", "vc"), + ("euclidean", "ve"), + ("dot_product", "vd"), + ] +] + +# Property [queryVector Float32 Narrowing]: a query vector whose elements narrow +# to a finite, nonzero float32 value is accepted and returns results. +VECTORSEARCH_QUERY_VECTOR_FLOAT32_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + "qv_float32_max_boundary", + raw_res=True, + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [3.4028235e38, DOUBLE_ZERO, 1.0], + "numCandidates": 10, + "limit": 5, + } + }, + ], + expected={ + "cursor.firstBatch": [ + Len(5), + Contains("_id", 1), + Contains("_id", 2), + Contains("_id", 3), + Contains("_id", 4), + Contains("_id", 5), + ] + }, + msg="$vectorSearch should accept a queryVector component at the FLT_MAX boundary", + ), + VectorSearchTest( + "qv_float32_underflow_nonzero", + raw_res=True, + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1e-50, DOUBLE_ZERO, 1.0], + "numCandidates": 10, + "limit": 5, + } + }, + ], + expected={ + "cursor.firstBatch": [ + Len(5), + Contains("_id", 1), + Contains("_id", 2), + Contains("_id", 3), + Contains("_id", 4), + Contains("_id", 5), + ] + }, + msg="$vectorSearch should accept a queryVector component that underflows to " + "zero while the vector stays nonzero", + ), +] + +# Property [queryVector Zero Vector Acceptance]: a zero query vector ([0,0,0]) is +# accepted by euclidean and dotProduct indexes and returns results. +VECTORSEARCH_QUERY_VECTOR_ZERO_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + f"qv_zero_{tid}", + raw_res=True, + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": path, + "queryVector": qv, + "numCandidates": 10, + "limit": 5, + } + }, + ], + expected={ + "cursor.firstBatch": [ + Len(5), + Contains("_id", 1), + Contains("_id", 2), + Contains("_id", 3), + Contains("_id", 4), + Contains("_id", 5), + ] + }, + msg=f"$vectorSearch should accept a {tid} queryVector and return results", + ) + for tid, path, qv in [ + ("all_euclidean", "ve", [DOUBLE_ZERO, DOUBLE_ZERO, DOUBLE_ZERO]), + ("all_dot_product", "vd", [DOUBLE_ZERO, DOUBLE_ZERO, DOUBLE_ZERO]), + ] +] + +# Property [queryVector Subtype Mismatch Silent Miss]: a BinData query vector +# whose element subtype differs from the indexed float32 returns zero results +# with no error. +VECTORSEARCH_QUERY_VECTOR_SUBTYPE_MISS_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + "qv_int8_subtype_cosine", + raw_res=True, + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": Binary.from_vector([1, 0, 0], BinaryVectorDtype.INT8), + "numCandidates": 10, + "limit": 5, + } + }, + ], + expected={"cursor.firstBatch": Len(0)}, + msg="$vectorSearch should silently return no results for an int8 BinData " + "queryVector against a float32 cosine index", + ), + VectorSearchTest( + "qv_int8_subtype_euclidean", + raw_res=True, + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "ve", + "queryVector": Binary.from_vector([1, 0, 0], BinaryVectorDtype.INT8), + "numCandidates": 10, + "limit": 5, + } + }, + ], + expected={"cursor.firstBatch": Len(0)}, + msg="$vectorSearch should silently return no results for an int8 BinData " + "queryVector against a float32 euclidean index", + ), + VectorSearchTest( + "qv_packed_bit_subtype_euclidean", + raw_res=True, + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "v8", + "queryVector": Binary.from_vector( + [0b10110010], BinaryVectorDtype.PACKED_BIT, padding=0 + ), + "numCandidates": 10, + "limit": 5, + } + }, + ], + expected={"cursor.firstBatch": Len(0)}, + msg="$vectorSearch should silently return no results for a packed_bit " + "BinData queryVector against a float32 euclidean index", + ), +] + +VECTORSEARCH_QUERY_VECTOR_ALL_TESTS = ( + VECTORSEARCH_QUERY_VECTOR_EQUIVALENCE_TESTS + + VECTORSEARCH_QUERY_VECTOR_SIGN_TESTS + + VECTORSEARCH_QUERY_VECTOR_FLOAT32_TESTS + + VECTORSEARCH_QUERY_VECTOR_ZERO_TESTS + + VECTORSEARCH_QUERY_VECTOR_SUBTYPE_MISS_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(VECTORSEARCH_QUERY_VECTOR_ALL_TESTS)) +def test_vectorSearch_query_vector(test_case: VectorSearchTest, engine_client, request): + """$vectorSearch: queryVector accepted forms.""" + coll = request.getfixturevalue(test_case.collection_fixture) + result = execute_command( + coll, + {"aggregate": coll.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + raw_res=test_case.raw_res, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_query_vector_errors.py b/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_query_vector_errors.py new file mode 100644 index 000000000..605e1a866 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_query_vector_errors.py @@ -0,0 +1,327 @@ +"""Tests for the $vectorSearch stage: queryVector rejections.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Code, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) +from bson.binary import Binary, BinaryVectorDtype + +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + UNKNOWN_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_ONE_AND_HALF, + DOUBLE_MIN_SUBNORMAL, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, +) + +from .utils.vectorSearch_common import ( + VectorSearchTest, +) + +pytestmark = pytest.mark.requires(search=True) + +# Property [queryVector Scalar Type Rejection]: a non-array, non-BinData scalar +# queryVector is rejected as an unexpected vector type with no coercion. +VECTORSEARCH_QUERY_VECTOR_SCALAR_ERROR_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + f"query_vector_scalar_{tid}", + collection_fixture="vector_search_no_index_collection", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": val, + "numCandidates": 10, + "limit": 5, + } + } + ], + error_code=UNKNOWN_ERROR, + msg=f"$vectorSearch should reject a {tid} scalar queryVector as an unexpected type", + ) + for tid, val in [ + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool", True), + ("string", "vec"), + ("object", {"a": 1}), + ("objectid", ObjectId("5a9427648b0beebeb69537a5")), + ("datetime", datetime(2020, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [queryVector Element Type Rejection]: an array queryVector containing a +# non-numeric or Decimal128 element is rejected as an unsupported BSON value. +VECTORSEARCH_QUERY_VECTOR_ELEMENT_ERROR_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + f"query_vector_element_{tid}", + collection_fixture="vector_search_no_index_collection", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [val, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + } + } + ], + error_code=UNKNOWN_ERROR, + msg=f"$vectorSearch should reject a queryVector with a {tid} element as an " + "unsupported BSON value", + ) + for tid, val in [ + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool", True), + ("string", "x"), + ("null", None), + ("object", {"a": 1}), + ("nested_array", [1.0]), + ("objectid", ObjectId("5a9427648b0beebeb69537a5")), + ("datetime", datetime(2020, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [queryVector Literal Not Expression]: a queryVector given as a field +# reference, expression object, or variable is treated as a literal value and +# rejected without evaluation or array unwrapping. +VECTORSEARCH_QUERY_VECTOR_LITERAL_ERROR_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + f"query_vector_literal_{tid}", + collection_fixture="vector_search_no_index_collection", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": qv, + "numCandidates": 10, + "limit": 5, + } + } + ], + error_code=UNKNOWN_ERROR, + msg=f"$vectorSearch should treat a {tid} queryVector as a literal, not " + "evaluate it as an expression", + ) + for tid, qv in [ + ("field_ref", "$embedding"), + ("now_variable", "$$NOW"), + ("literal_expr", {"$literal": [1.0, DOUBLE_ZERO, DOUBLE_ZERO]}), + ("array_element_ref", ["$n", DOUBLE_ZERO, DOUBLE_ZERO]), + ] +] + +# Property [queryVector Binary Subtype Rejection]: a plain Binary queryVector +# whose subtype is not the vector subtype is rejected. +VECTORSEARCH_QUERY_VECTOR_BINARY_SUBTYPE_ERROR_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + "query_vector_binary_subtype_zero", + collection_fixture="vector_search_no_index_collection", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": Binary(b"\x00\x01\x02", 0), + "numCandidates": 10, + "limit": 5, + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$vectorSearch should reject a subtype-0 Binary queryVector", + ), +] + +# Property [queryVector Dimension Mismatch]: a queryVector whose element count +# differs from the index numDimensions is rejected, including an empty array. +VECTORSEARCH_QUERY_VECTOR_DIMENSION_ERROR_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + f"query_vector_dimension_{tid}", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": qv, + "numCandidates": 10, + "limit": 5, + } + } + ], + error_code=UNKNOWN_ERROR, + msg=f"$vectorSearch should reject a queryVector of length {n} against a " + f"{3}-dimension index", + ) + for tid, qv, n in [ + ("empty", [], 0), + ("too_few_one", [1.0], 1), + ("too_few_two", [1.0, DOUBLE_ZERO], 2), + ("too_many_four", [1.0, DOUBLE_ZERO, DOUBLE_ZERO, DOUBLE_ZERO], 4), + ] +] + +# Property [queryVector Non-Finite Rejection]: a non-finite queryVector element +# is rejected, including a value that overflows float32 to Infinity. +VECTORSEARCH_QUERY_VECTOR_NON_FINITE_ERROR_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + f"query_vector_non_finite_{tid}", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [val, DOUBLE_ZERO, 1.0], + "numCandidates": 10, + "limit": 5, + } + } + ], + error_code=UNKNOWN_ERROR, + msg=f"$vectorSearch should reject a {tid} queryVector element as non-finite", + ) + for tid, val in [ + ("nan", FLOAT_NAN), + ("positive_infinity", FLOAT_INFINITY), + ("negative_infinity", FLOAT_NEGATIVE_INFINITY), + ("float32_overflow", 3.5e38), + ] +] + +# Property [queryVector Cosine Zero Vector Rejection]: a zero queryVector, +# including values that float32-narrow to all-zero, is rejected against a cosine +# index. +VECTORSEARCH_QUERY_VECTOR_ZERO_ERROR_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + f"query_vector_zero_{tid}", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": qv, + "numCandidates": 10, + "limit": 5, + } + } + ], + error_code=UNKNOWN_ERROR, + msg=f"$vectorSearch should reject a {tid} queryVector against a cosine index", + ) + for tid, qv in [ + ("all_zero", [DOUBLE_ZERO, DOUBLE_ZERO, DOUBLE_ZERO]), + ("negative_zero", [DOUBLE_NEGATIVE_ZERO, DOUBLE_ZERO, DOUBLE_ZERO]), + ("float32_underflow", [DOUBLE_MIN_SUBNORMAL, DOUBLE_ZERO, DOUBLE_ZERO]), + ] +] + +# Property [queryVector Packed-Bit Cosine Rejection]: a packed_bit BinData +# queryVector is rejected against a cosine-indexed field because binary vectors +# require euclidean similarity. +VECTORSEARCH_QUERY_VECTOR_PACKED_BIT_COSINE_ERROR_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + "query_vector_packed_bit_cosine", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "v8c", + "queryVector": Binary.from_vector( + [0b10110010], BinaryVectorDtype.PACKED_BIT, padding=0 + ), + "numCandidates": 10, + "limit": 5, + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$vectorSearch should reject a packed_bit queryVector against a cosine index", + ), +] + +# Property [queryVector Packed-Bit Structural Rejection]: a packed_bit BinData +# queryVector with nonzero padding is rejected before the dimension-count and +# similarity checks run. +VECTORSEARCH_QUERY_VECTOR_PACKED_BIT_STRUCTURE_ERROR_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + "query_vector_packed_bit_nonzero_padding", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "v8", + "queryVector": Binary.from_vector( + [0b10110000], BinaryVectorDtype.PACKED_BIT, padding=3 + ), + "numCandidates": 10, + "limit": 5, + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$vectorSearch should reject a packed_bit queryVector with nonzero padding", + ), +] + +VECTORSEARCH_QUERY_VECTOR_ERRORS_ALL_TESTS = ( + VECTORSEARCH_QUERY_VECTOR_SCALAR_ERROR_TESTS + + VECTORSEARCH_QUERY_VECTOR_ELEMENT_ERROR_TESTS + + VECTORSEARCH_QUERY_VECTOR_LITERAL_ERROR_TESTS + + VECTORSEARCH_QUERY_VECTOR_BINARY_SUBTYPE_ERROR_TESTS + + VECTORSEARCH_QUERY_VECTOR_DIMENSION_ERROR_TESTS + + VECTORSEARCH_QUERY_VECTOR_NON_FINITE_ERROR_TESTS + + VECTORSEARCH_QUERY_VECTOR_ZERO_ERROR_TESTS + + VECTORSEARCH_QUERY_VECTOR_PACKED_BIT_COSINE_ERROR_TESTS + + VECTORSEARCH_QUERY_VECTOR_PACKED_BIT_STRUCTURE_ERROR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(VECTORSEARCH_QUERY_VECTOR_ERRORS_ALL_TESTS)) +def test_vectorSearch_query_vector_errors(test_case: VectorSearchTest, engine_client, request): + """$vectorSearch: queryVector rejections.""" + coll = request.getfixturevalue(test_case.collection_fixture) + result = execute_command( + coll, + {"aggregate": coll.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_required_field_errors.py b/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_required_field_errors.py new file mode 100644 index 000000000..d5dec3aaf --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_required_field_errors.py @@ -0,0 +1,253 @@ +"""Tests for the $vectorSearch stage: required-field and null-as-absent errors.""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + EXPRESSION_NOT_OBJECT_ERROR, + UNKNOWN_ERROR, + VECTOR_SEARCH_LIMIT_NOT_NUMBER_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DOUBLE_ZERO, +) + +from .utils.vectorSearch_common import ( + VectorSearchTest, +) + +pytestmark = pytest.mark.requires(search=True) + +# Property [Required Fields and Null-as-Absent]: each required field, when +# omitted or set to null, surfaces that field's required-field error with null +# treated as field-absent, except limit null (a type error) and filter null (a +# parse error), which are not treated as absent. +VECTORSEARCH_REQUIRED_FIELD_ERROR_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + "index_omitted", + collection_fixture="vector_search_no_index_collection", + pipeline=[ + { + "$vectorSearch": { + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$vectorSearch should require the index field when it is omitted", + ), + VectorSearchTest( + "index_null", + collection_fixture="vector_search_no_index_collection", + pipeline=[ + { + "$vectorSearch": { + "index": None, + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$vectorSearch should treat index null as field-absent and require the index field", + ), + VectorSearchTest( + "path_omitted", + collection_fixture="vector_search_no_index_collection", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$vectorSearch should require the path field when it is omitted", + ), + VectorSearchTest( + "path_null", + collection_fixture="vector_search_no_index_collection", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": None, + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$vectorSearch should treat path null as field-absent and require the path field", + ), + VectorSearchTest( + "query_vector_omitted", + collection_fixture="vector_search_no_index_collection", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "numCandidates": 10, + "limit": 5, + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$vectorSearch should require queryVector when it is omitted", + ), + VectorSearchTest( + "query_vector_null", + collection_fixture="vector_search_no_index_collection", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": None, + "numCandidates": 10, + "limit": 5, + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$vectorSearch should treat queryVector null as field-absent and require queryVector", + ), + VectorSearchTest( + "limit_omitted", + collection_fixture="vector_search_no_index_collection", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$vectorSearch should require the limit field when it is omitted", + ), + VectorSearchTest( + "limit_omitted_enn", + collection_fixture="vector_search_no_index_collection", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "exact": True, + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$vectorSearch should require the limit field when it is omitted under ENN", + ), + VectorSearchTest( + "limit_null", + collection_fixture="vector_search_no_index_collection", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": None, + } + } + ], + error_code=VECTOR_SEARCH_LIMIT_NOT_NUMBER_ERROR, + msg="$vectorSearch should treat limit null as a wrong type rather than field-absent", + ), + VectorSearchTest( + "num_candidates_omitted", + collection_fixture="vector_search_no_index_collection", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "limit": 5, + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$vectorSearch should require numCandidates for ANN when it is omitted", + ), + VectorSearchTest( + "num_candidates_null", + collection_fixture="vector_search_no_index_collection", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": None, + "limit": 5, + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$vectorSearch should treat numCandidates null as field-absent and require it for ANN", + ), + VectorSearchTest( + "filter_null", + collection_fixture="vector_search_no_index_collection", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + "filter": None, + } + } + ], + error_code=EXPRESSION_NOT_OBJECT_ERROR, + msg="$vectorSearch should reject filter null as a non-object, not treat it as omitted", + ), + VectorSearchTest( + "empty_spec", + collection_fixture="vector_search_no_index_collection", + pipeline=[{"$vectorSearch": {}}], + error_code=UNKNOWN_ERROR, + msg="$vectorSearch should require the index field for an empty spec", + ), +] + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(VECTORSEARCH_REQUIRED_FIELD_ERROR_TESTS)) +def test_vectorSearch_required_field_errors(test_case: VectorSearchTest, engine_client, request): + """$vectorSearch: required-field and null-as-absent errors.""" + coll = request.getfixturevalue(test_case.collection_fixture) + result = execute_command( + coll, + {"aggregate": coll.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_scoring.py b/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_scoring.py new file mode 100644 index 000000000..fee84bfd3 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_scoring.py @@ -0,0 +1,202 @@ +"""Tests for the $vectorSearch stage: score similarity and range.""" + +from __future__ import annotations + +import pytest + +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.property_checks import ( + Gte, + Len, + Lte, +) +from documentdb_tests.framework.test_constants import ( + DOUBLE_ZERO, +) + +from .utils.vectorSearch_common import ( + VectorSearchTest, +) + +pytestmark = pytest.mark.requires(search=True) + +# Property [Score Similarity Function]: the score is computed per the index +# similarity function, so cosine, euclidean, and dotProduct indexes each produce +# their own scores for the same data and query. +VECTORSEARCH_SCORE_SIMILARITY_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + "similarity_cosine_scores", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + } + }, + {"$project": {"_id": 1, "score": {"$meta": "vectorSearchScore"}}}, + ], + expected=[ + {"_id": 1, "score": pytest.approx(1.0)}, + {"_id": 2, "score": pytest.approx(0.9850712418556213)}, + {"_id": 3, "score": pytest.approx(0.9160251617431641)}, + {"_id": 4, "score": pytest.approx(0.6212677955627441)}, + {"_id": 5, "score": pytest.approx(0.5)}, + ], + msg="$vectorSearch should produce cosine similarity scores for a cosine index", + ), + VectorSearchTest( + "similarity_euclidean_scores", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "ve", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + } + }, + {"$project": {"_id": 1, "score": {"$meta": "vectorSearchScore"}}}, + ], + expected=[ + {"_id": 1, "score": pytest.approx(1.0)}, + {"_id": 2, "score": pytest.approx(0.9259259104728699)}, + {"_id": 3, "score": pytest.approx(0.7575758099555969)}, + {"_id": 4, "score": pytest.approx(0.4385964572429657)}, + {"_id": 5, "score": pytest.approx(0.3333333432674408)}, + ], + msg="$vectorSearch should produce euclidean similarity scores for a euclidean index", + ), + VectorSearchTest( + "similarity_dot_product_scores", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vd", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + } + }, + {"$project": {"_id": 1, "score": {"$meta": "vectorSearchScore"}}}, + ], + expected=[ + {"_id": 1, "score": pytest.approx(1.0)}, + {"_id": 2, "score": pytest.approx(0.8999999761581421)}, + {"_id": 3, "score": pytest.approx(0.800000011920929)}, + {"_id": 4, "score": pytest.approx(0.6000000238418579)}, + {"_id": 5, "score": pytest.approx(0.5)}, + ], + msg="$vectorSearch should produce dotProduct similarity scores for a dotProduct index", + ), +] + +# Property [Score Filter Invariance]: a pre-filter narrows the candidate set but +# leaves a surviving document's vectorSearchScore identical to the unfiltered score. +VECTORSEARCH_SCORE_FILTER_INVARIANCE_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + "filter_preserves_surviving_scores", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + "filter": {"cat": "x"}, + } + }, + {"$project": {"_id": 1, "score": {"$meta": "vectorSearchScore"}}}, + ], + expected=[ + {"_id": 1, "score": pytest.approx(1.0)}, + {"_id": 2, "score": pytest.approx(0.9850712418556213)}, + ], + msg="$vectorSearch should keep a surviving document's score identical under a filter", + ), +] + +# Property [searchScore Omitted]: requesting the unpopulated metadata name +# searchScore omits the projected field rather than erroring or populating it, +# because $vectorSearch populates vectorSearchScore, not searchScore. +VECTORSEARCH_SEARCHSCORE_OMITTED_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + "search_score_metadata_omitted", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 2, + } + }, + {"$project": {"_id": 1, "score": {"$meta": "searchScore"}}}, + ], + expected=[{"_id": 1}, {"_id": 2}], + msg="$vectorSearch should omit the searchScore field rather than error or populate it", + ), +] + +# Property [Score Range]: every returned document is assigned a vectorSearchScore +# that falls within the closed interval [0, 1]. +VECTORSEARCH_SCORE_RANGE_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + "score_range_within_unit_interval", + raw_res=True, + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + } + }, + {"$project": {"_id": 0, "score": {"$meta": "vectorSearchScore"}}}, + ], + expected={ + "cursor.firstBatch": Len(5), + "cursor.firstBatch.0.score": [Gte(0), Lte(1)], + "cursor.firstBatch.1.score": [Gte(0), Lte(1)], + "cursor.firstBatch.2.score": [Gte(0), Lte(1)], + "cursor.firstBatch.3.score": [Gte(0), Lte(1)], + "cursor.firstBatch.4.score": [Gte(0), Lte(1)], + }, + msg="$vectorSearch should assign every result a score within [0, 1]", + ), +] + +VECTORSEARCH_SCORING_ALL_TESTS = ( + VECTORSEARCH_SCORE_SIMILARITY_TESTS + + VECTORSEARCH_SCORE_FILTER_INVARIANCE_TESTS + + VECTORSEARCH_SEARCHSCORE_OMITTED_TESTS + + VECTORSEARCH_SCORE_RANGE_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(VECTORSEARCH_SCORING_ALL_TESTS)) +def test_vectorSearch_scoring(test_case: VectorSearchTest, engine_client, request): + """$vectorSearch: score similarity and range.""" + coll = request.getfixturevalue(test_case.collection_fixture) + result = execute_command( + coll, + {"aggregate": coll.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + raw_res=test_case.raw_res, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_search_node_preference.py b/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_search_node_preference.py new file mode 100644 index 000000000..9cbd96a68 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_search_node_preference.py @@ -0,0 +1,271 @@ +"""Tests for the $vectorSearch stage: searchNodePreference behavior and errors.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Code, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) +from bson.binary import Binary + +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + UNKNOWN_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import ( + Contains, + Len, +) +from documentdb_tests.framework.test_constants import ( + DECIMAL128_ONE_AND_HALF, + DOUBLE_ZERO, +) + +from .utils.vectorSearch_common import ( + VectorSearchTest, +) + +pytestmark = pytest.mark.requires(search=True) + +# Property [searchNodePreference Accepted No Effect]: a searchNodePreference +# specification is accepted and recognized without changing the result set, +# whether it carries extra keys alongside a valid key or is null (treated as +# omitted). +VECTORSEARCH_SEARCH_NODE_PREFERENCE_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + "search_node_preference_key", + raw_res=True, + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + "searchNodePreference": {"key": "n1"}, + } + }, + ], + expected={ + "cursor.firstBatch": [ + Len(5), + Contains("_id", 1), + Contains("_id", 2), + Contains("_id", 3), + Contains("_id", 4), + Contains("_id", 5), + ] + }, + msg="$vectorSearch should accept a searchNodePreference key with no effect " + "on the result set", + ), + VectorSearchTest( + "search_node_preference_extra_keys", + raw_res=True, + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + "searchNodePreference": {"key": "n1", "extra": "x"}, + } + }, + ], + expected={ + "cursor.firstBatch": [ + Len(5), + Contains("_id", 1), + Contains("_id", 2), + Contains("_id", 3), + Contains("_id", 4), + Contains("_id", 5), + ] + }, + msg="$vectorSearch should silently ignore extra keys alongside a valid " + "searchNodePreference key", + ), + VectorSearchTest( + "search_node_preference_null", + raw_res=True, + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + "searchNodePreference": None, + } + }, + ], + expected={ + "cursor.firstBatch": [ + Len(5), + Contains("_id", 1), + Contains("_id", 2), + Contains("_id", 3), + Contains("_id", 4), + Contains("_id", 5), + ] + }, + msg="$vectorSearch should treat a null searchNodePreference as omitted and succeed", + ), +] + +# Property [searchNodePreference Type Rejection]: a searchNodePreference value of +# any non-document BSON type, including an array, is rejected as not a document +# with no coercion. +VECTORSEARCH_SEARCH_NODE_PREFERENCE_TYPE_ERROR_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + f"search_node_preference_type_{tid}", + collection_fixture="vector_search_no_index_collection", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + "searchNodePreference": val, + } + } + ], + error_code=UNKNOWN_ERROR, + msg=f"$vectorSearch should reject a {tid} searchNodePreference value as not a document", + ) + for tid, val in [ + ("string", "n1"), + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool", True), + ("array", []), + ("objectid", ObjectId("5a9427648b0beebeb69537a5")), + ("datetime", datetime(2020, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [searchNodePreference Key Required]: a searchNodePreference that omits +# its key, or gives a null key (treated as field-absent, not a type error), +# requires the key. +VECTORSEARCH_SEARCH_NODE_PREFERENCE_KEY_REQUIRED_ERROR_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + f"search_node_preference_{tid}", + collection_fixture="vector_search_no_index_collection", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + "searchNodePreference": snp, + } + } + ], + error_code=UNKNOWN_ERROR, + msg=msg, + ) + for tid, snp, msg in [ + ( + "key_omitted", + {}, + "$vectorSearch should require the searchNodePreference key when it is omitted", + ), + ( + "key_null", + {"key": None}, + "$vectorSearch should treat a null searchNodePreference key as field-absent " + "and require it", + ), + ] +] + +# Property [searchNodePreference Key Type Rejection]: a searchNodePreference key +# of any non-string BSON type is rejected as a non-string with no coercion. +VECTORSEARCH_SEARCH_NODE_PREFERENCE_KEY_TYPE_ERROR_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + f"search_node_preference_key_type_{tid}", + collection_fixture="vector_search_no_index_collection", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + "searchNodePreference": {"key": val}, + } + } + ], + error_code=UNKNOWN_ERROR, + msg=f"$vectorSearch should reject a {tid} searchNodePreference key as a non-string", + ) + for tid, val in [ + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool", True), + ("array", ["a"]), + ("object", {"a": 1}), + ("objectid", ObjectId("5a9427648b0beebeb69537a5")), + ("datetime", datetime(2020, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +VECTORSEARCH_SEARCH_NODE_PREFERENCE_ALL_TESTS = ( + VECTORSEARCH_SEARCH_NODE_PREFERENCE_TESTS + + VECTORSEARCH_SEARCH_NODE_PREFERENCE_TYPE_ERROR_TESTS + + VECTORSEARCH_SEARCH_NODE_PREFERENCE_KEY_REQUIRED_ERROR_TESTS + + VECTORSEARCH_SEARCH_NODE_PREFERENCE_KEY_TYPE_ERROR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(VECTORSEARCH_SEARCH_NODE_PREFERENCE_ALL_TESTS)) +def test_vectorSearch_search_node_preference(test_case: VectorSearchTest, engine_client, request): + """$vectorSearch: searchNodePreference behavior and errors.""" + coll = request.getfixturevalue(test_case.collection_fixture) + result = execute_command( + coll, + {"aggregate": coll.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + raw_res=test_case.raw_res, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_stage_basics.py b/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_stage_basics.py new file mode 100644 index 000000000..743827a2f --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_stage_basics.py @@ -0,0 +1,218 @@ +"""Tests for the $vectorSearch stage: stage-level acceptance and errors.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Code, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) +from bson.binary import Binary + +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + QUERY_METADATA_NOT_AVAILABLE_ERROR, + UNKNOWN_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import ( + Contains, + Len, +) +from documentdb_tests.framework.test_constants import ( + DECIMAL128_ONE_AND_HALF, + DOUBLE_ZERO, +) + +from .utils.vectorSearch_common import ( + VectorSearchTest, +) + +pytestmark = pytest.mark.requires(search=True) + +# Property [quantization Silently Ignored]: quantization is accepted with no +# validation regardless of value or BSON type and produces results identical to +# omitting it, never surfacing a value or type error. +VECTORSEARCH_QUANTIZATION_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + f"quantization_{tid}", + raw_res=True, + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + "quantization": value, + } + }, + ], + expected={ + "cursor.firstBatch": [ + Len(5), + Contains("_id", 1), + Contains("_id", 2), + Contains("_id", 3), + Contains("_id", 4), + Contains("_id", 5), + ] + }, + msg=f"$vectorSearch should silently ignore a {tid} quantization value and " + "return the same results as omitting it", + ) + for tid, value in [ + ("scalar", "scalar"), + ("bogus", "bogus"), + ("int32", 1), + ("int64", Int64(2)), + ("double", 1.5), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool", True), + ("array", [1]), + ("object", {"a": 1}), + ("objectid", ObjectId("5a9427648b0beebeb69537a5")), + ("datetime", datetime(2020, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("null", None), + ] +] + +# Property [Unknown Spec Field Silently Ignored]: an unrecognized top-level spec +# field alongside all required fields is silently ignored and the query succeeds +# with results identical to omitting it, rather than raising an unknown-field error. +VECTORSEARCH_UNKNOWN_FIELD_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + "unknown_field_ignored", + raw_res=True, + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + "bogus": 1, + } + }, + ], + expected={ + "cursor.firstBatch": [ + Len(5), + Contains("_id", 1), + Contains("_id", 2), + Contains("_id", 3), + Contains("_id", 4), + Contains("_id", 5), + ] + }, + msg="$vectorSearch should silently ignore an unrecognized top-level spec " + "field and return the same results as omitting it", + ), +] + +# Property [model/query Mutual Exclusivity]: model or query supplied alongside +# queryVector is rejected, because model is autoEmbed-only and exactly one of +# query and queryVector may be present, while both fields are still recognized by +# the parser. +VECTORSEARCH_MODEL_QUERY_EXCLUSIVITY_ERROR_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + "model_with_query_vector", + collection_fixture="vector_search_no_index_collection", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + "model": "voyage-3", + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$vectorSearch should reject model supplied alongside queryVector", + ), + VectorSearchTest( + "query_with_query_vector", + collection_fixture="vector_search_no_index_collection", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + "query": {"text": "hello"}, + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$vectorSearch should reject query supplied alongside queryVector", + ), +] + +# Property [Score Metadata Unavailable]: requesting metadata that $vectorSearch +# does not produce (textScore) in a following $project fails, because the stage +# populates vectorSearchScore, not text-score metadata. +VECTORSEARCH_SCORE_METADATA_ERROR_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + "score_metadata_text_score_unavailable", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + } + }, + {"$project": {"_id": 1, "score": {"$meta": "textScore"}}}, + ], + error_code=QUERY_METADATA_NOT_AVAILABLE_ERROR, + msg="$vectorSearch should not provide text-score metadata for a following $meta textScore", + ), +] + +VECTORSEARCH_STAGE_BASICS_ALL_TESTS = ( + VECTORSEARCH_QUANTIZATION_TESTS + + VECTORSEARCH_UNKNOWN_FIELD_TESTS + + VECTORSEARCH_MODEL_QUERY_EXCLUSIVITY_ERROR_TESTS + + VECTORSEARCH_SCORE_METADATA_ERROR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(VECTORSEARCH_STAGE_BASICS_ALL_TESTS)) +def test_vectorSearch_stage_basics(test_case: VectorSearchTest, engine_client, request): + """$vectorSearch: stage-level acceptance and errors.""" + coll = request.getfixturevalue(test_case.collection_fixture) + result = execute_command( + coll, + {"aggregate": coll.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + raw_res=test_case.raw_res, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_stored_source.py b/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_stored_source.py new file mode 100644 index 000000000..028bbe900 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/test_vectorSearch_stored_source.py @@ -0,0 +1,242 @@ +"""Tests for the $vectorSearch stage: returnStoredSource behavior and errors.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Code, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) +from bson.binary import Binary + +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + UNKNOWN_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import ( + Eq, + NotExists, + PerDoc, +) +from documentdb_tests.framework.test_constants import ( + DECIMAL128_ONE_AND_HALF, + DOUBLE_ZERO, +) + +from .utils.vectorSearch_common import ( + VectorSearchTest, + wait_for_search_index_ready, +) + +pytestmark = pytest.mark.requires(search=True) + +_STORED_SOURCE_CORPUS = [ + { + "_id": 1, + "name": "a", + "title": "T1", + "body": "B1", + "embedding": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + }, + { + "_id": 2, + "name": "b", + "title": "T2", + "body": "B2", + "embedding": [DOUBLE_ZERO, 1.0, DOUBLE_ZERO], + }, + # Doc 3 intentionally lacks the "title" field. + {"_id": 3, "name": "c", "body": "B3", "embedding": [0.9, 0.1, DOUBLE_ZERO]}, +] + + +@pytest.fixture(scope="module") +def stored_source_vector_search_collection(engine_client, worker_id): + """Provide a collection with a READY vectorSearch index configured with storedSource.""" + db_name = f"vs_stored_source_{worker_id}" + db = engine_client[db_name] + coll = db["stored_source_vectors"] + db.drop_collection(coll.name) + db.create_collection(coll.name) + coll.insert_many([dict(doc) for doc in _STORED_SOURCE_CORPUS]) + db.command( + { + "createSearchIndexes": coll.name, + "indexes": [ + { + "name": "vs_stored_source_index", + "type": "vectorSearch", + "definition": { + "storedSource": {"include": ["name"]}, + "fields": [ + { + "type": "vector", + "path": "embedding", + "numDimensions": 3, + "similarity": "cosine", + }, + ], + }, + } + ], + } + ) + wait_for_search_index_ready(coll) + yield coll + engine_client.drop_database(db_name) + + +# Property [returnStoredSource Full Documents]: against a storedSource-configured +# index, both returnStoredSource false (the default) and true return the full +# document with all fields, because the configured stored-source field +# restriction is non-functional on this target. +VECTORSEARCH_RETURN_STORED_SOURCE_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + f"return_stored_source_{tid}", + collection_fixture="stored_source_vector_search_collection", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_stored_source_index", + "path": "embedding", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + "returnStoredSource": rss, + } + }, + {"$sort": {"_id": 1}}, + ], + expected=PerDoc( + {"_id": Eq(1), "name": Eq("a"), "title": Eq("T1"), "body": Eq("B1")}, + {"_id": Eq(2), "name": Eq("b"), "title": Eq("T2"), "body": Eq("B2")}, + {"_id": Eq(3), "name": Eq("c"), "title": NotExists(), "body": Eq("B3")}, + ), + msg=f"$vectorSearch should return the full document when returnStoredSource is {tid}", + ) + for tid, rss in [("false", False), ("true", True)] +] + +# Property [returnStoredSource Null As Omitted]: returnStoredSource null is +# treated as field-absent and the query succeeds with full documents as if the +# field were omitted, rather than triggering the not-configured error that +# returnStoredSource true raises on an index without a storedSource configuration. +VECTORSEARCH_RETURN_STORED_SOURCE_NULL_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + "return_stored_source_null_omitted", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + "returnStoredSource": None, + } + }, + ], + expected=PerDoc( + {"_id": Eq(1)}, {"_id": Eq(2)}, {"_id": Eq(3)}, {"_id": Eq(4)}, {"_id": Eq(5)} + ), + msg="$vectorSearch should treat returnStoredSource null as omitted and succeed " + "on an index without a storedSource configuration", + ), +] + +# Property [returnStoredSource Type Rejection]: a non-boolean returnStoredSource +# value is rejected as not a boolean, with no coercion. +VECTORSEARCH_RETURN_STORED_SOURCE_TYPE_ERROR_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + f"return_stored_source_type_{tid}", + collection_fixture="vector_search_no_index_collection", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + "returnStoredSource": val, + } + } + ], + error_code=UNKNOWN_ERROR, + msg=f"$vectorSearch should reject a {tid} returnStoredSource value as a non-boolean", + ) + for tid, val in [ + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.0), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("string", "true"), + ("array", [True]), + ("object", {"a": 1}), + ("objectid", ObjectId("5a9427648b0beebeb69537a5")), + ("datetime", datetime(2020, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [returnStoredSource Not Configured]: returnStoredSource true against an +# index that has no storedSource configuration is rejected because stored source +# is not configured for that index. +VECTORSEARCH_RETURN_STORED_SOURCE_NOT_CONFIGURED_ERROR_TESTS: list[VectorSearchTest] = [ + VectorSearchTest( + "return_stored_source_not_configured", + pipeline=[ + { + "$vectorSearch": { + "index": "vs_core_index", + "path": "vc", + "queryVector": [1.0, DOUBLE_ZERO, DOUBLE_ZERO], + "numCandidates": 10, + "limit": 5, + "returnStoredSource": True, + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$vectorSearch should reject returnStoredSource true when the index has no " + "storedSource configuration", + ), +] + +VECTORSEARCH_STORED_SOURCE_ALL_TESTS = ( + VECTORSEARCH_RETURN_STORED_SOURCE_TESTS + + VECTORSEARCH_RETURN_STORED_SOURCE_NULL_TESTS + + VECTORSEARCH_RETURN_STORED_SOURCE_TYPE_ERROR_TESTS + + VECTORSEARCH_RETURN_STORED_SOURCE_NOT_CONFIGURED_ERROR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(VECTORSEARCH_STORED_SOURCE_ALL_TESTS)) +def test_vectorSearch_stored_source(test_case: VectorSearchTest, engine_client, request): + """$vectorSearch: returnStoredSource behavior and errors.""" + coll = request.getfixturevalue(test_case.collection_fixture) + result = execute_command( + coll, + {"aggregate": coll.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/utils/__init__.py b/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/utils/vectorSearch_common.py b/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/utils/vectorSearch_common.py new file mode 100644 index 000000000..c68e172ad --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/vectorSearch/utils/vectorSearch_common.py @@ -0,0 +1,65 @@ +"""Shared dataclass, index-ready helper, and pre-filter constants for +$vectorSearch stage tests. + +The ``VectorSearchTest`` dataclass tags each case with the fixture and execution +mode it runs under. ``wait_for_search_index_ready`` is shared by every fixture +that builds an index. The ObjectId/UUID constants are stored on the shared +corpus (see conftest.py) and queried back by the filter, parentFilter, and +explainOptions test files, so they live here rather than in any single file.""" + +from __future__ import annotations + +import time +import uuid +from dataclasses import dataclass + +from bson import ObjectId +from bson.binary import Binary, UuidRepresentation +from pymongo.collection import Collection + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, +) + +_INDEX_READY_TIMEOUT_SECONDS = 120 + + +def wait_for_search_index_ready(coll: Collection) -> None: + """Poll until the collection's search index reports READY, or time out. + + A vectorSearch index builds asynchronously after ``createSearchIndexes`` + returns, so a query issued before it is READY sees no index. This polls + ``$listSearchIndexes`` until the first index reports READY.""" + deadline = time.monotonic() + _INDEX_READY_TIMEOUT_SECONDS + while time.monotonic() < deadline: + indexes = list(coll.aggregate([{"$listSearchIndexes": {}}])) + if indexes and indexes[0].get("status") == "READY": + return + time.sleep(2) + raise TimeoutError("vectorSearch index did not reach READY state") + + +@dataclass(frozen=True) +class VectorSearchTest(StageTestCase): + """A $vectorSearch case, tagged with the collection fixture and execution mode it runs under.""" + + collection_fixture: str = "vector_search_collection" + explain: bool = False + raw_res: bool = False + + +# ObjectId and UUID values for the filter pre-filtering tests. The specific +# values are arbitrary; only the A/B partition matters: each is stored on some +# corpus docs and queried back, so a filter on "A" must return exactly the docs +# that stored "A" and none that stored "B". +_FILTER_OID_A = ObjectId("5a9427648b0beebeb69537a5") + +_FILTER_OID_B = ObjectId("5a9427648b0beebeb69537b6") + +_FILTER_UUID_A = Binary.from_uuid( + uuid.UUID("11111111-1111-1111-1111-111111111111"), UuidRepresentation.STANDARD +) + +_FILTER_UUID_B = Binary.from_uuid( + uuid.UUID("22222222-2222-2222-2222-222222222222"), UuidRepresentation.STANDARD +) diff --git a/documentdb_tests/framework/error_codes.py b/documentdb_tests/framework/error_codes.py index af7608d53..2b562bb09 100644 --- a/documentdb_tests/framework/error_codes.py +++ b/documentdb_tests/framework/error_codes.py @@ -545,7 +545,9 @@ PIPELINE_LENGTH_LIMIT_ERROR = 7749501 PERCENTILE_INVALID_P_FIELD_ERROR = 7750301 PERCENTILE_INVALID_P_VALUE_ERROR = 7750303 +VECTOR_SEARCH_LIMIT_NOT_POSITIVE_ERROR = 7912700 ENCRYPTED_FIELD_TRIM_FACTOR_OUT_OF_RANGE_ERROR = 8574000 +VECTOR_SEARCH_LIMIT_NOT_NUMBER_ERROR = 8575100 QUERYSETTINGS_INTERNAL_DB_ERROR = 8584900 QUERYSETTINGS_NS_DB_MISSING_ERROR = 8727500 QUERYSETTINGS_NS_COLL_MISSING_ERROR = 8727501 From b7b1a38354452ffcabf4772e335487f52f713015 Mon Sep 17 00:00:00 2001 From: Daniel Frankcom Date: Mon, 13 Jul 2026 11:58:27 -0700 Subject: [PATCH 21/35] Add $dateAdd, $dateSubtract, $dateDiff, $dateTrunc tests (#674) Signed-off-by: Daniel Frankcom Co-authored-by: Mitchell Elholm Co-authored-by: Daniel Frankcom Signed-off-by: RyanGarfinkel <113050972+RyanGarfinkel@users.noreply.github.com> --- .../expressions/date/dateAdd/__init__.py | 0 .../date/dateAdd/test_dateAdd_arithmetic.py | 276 +++++++++++ .../date/dateAdd/test_dateAdd_calendar.py | 246 ++++++++++ .../date/dateAdd/test_dateAdd_date_range.py | 130 ++++++ .../date/dateAdd/test_dateAdd_errors.py | 416 +++++++++++++++++ .../date/dateAdd/test_dateAdd_expressions.py | 89 ++++ .../date/dateAdd/test_dateAdd_input_types.py | 160 +++++++ .../date/dateAdd/test_dateAdd_null.py | 138 ++++++ .../date/dateAdd/test_dateAdd_timezone.py | 318 +++++++++++++ .../expressions/date/dateDiff/__init__.py | 0 .../date/dateDiff/test_dateDiff_basic.py | 185 ++++++++ .../date/dateDiff/test_dateDiff_counting.py | 390 ++++++++++++++++ .../date/dateDiff/test_dateDiff_errors.py | 438 ++++++++++++++++++ .../dateDiff/test_dateDiff_expressions.py | 91 ++++ .../dateDiff/test_dateDiff_input_types.py | 234 ++++++++++ .../date/dateDiff/test_dateDiff_null.py | 135 ++++++ .../date/dateDiff/test_dateDiff_range.py | 165 +++++++ .../date/dateDiff/test_dateDiff_timezone.py | 292 ++++++++++++ .../date/dateDiff/test_dateDiff_week.py | 167 +++++++ .../expressions/date/dateSubtract/__init__.py | 0 .../test_dateSubtract_arithmetic.py | 290 ++++++++++++ .../test_dateSubtract_calendar.py | 198 ++++++++ .../test_dateSubtract_date_range.py | 135 ++++++ .../dateSubtract/test_dateSubtract_errors.py | 433 +++++++++++++++++ .../test_dateSubtract_expressions.py | 91 ++++ .../test_dateSubtract_input_types.py | 161 +++++++ .../dateSubtract/test_dateSubtract_null.py | 140 ++++++ .../test_dateSubtract_timezone.py | 318 +++++++++++++ .../expressions/date/dateTrunc/__init__.py | 0 .../dateTrunc/test_dateTrunc_arguments.py | 102 ++++ .../test_dateTrunc_binsize_errors.py | 207 +++++++++ .../date/dateTrunc/test_dateTrunc_errors.py | 377 +++++++++++++++ .../dateTrunc/test_dateTrunc_expressions.py | 111 +++++ .../dateTrunc/test_dateTrunc_input_types.py | 193 ++++++++ .../date/dateTrunc/test_dateTrunc_null.py | 132 ++++++ .../date/dateTrunc/test_dateTrunc_range.py | 146 ++++++ .../date/dateTrunc/test_dateTrunc_timezone.py | 221 +++++++++ .../dateTrunc/test_dateTrunc_truncation.py | 264 +++++++++++ .../date/dateTrunc/test_dateTrunc_week.py | 203 ++++++++ .../test_dateAdd_dateSubtract_equivalence.py | 157 +++++++ ..._expressions_combination_date_operators.py | 138 ++++++ 41 files changed, 7887 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_arithmetic.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_calendar.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_date_range.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_expressions.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_input_types.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_null.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_timezone.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_basic.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_counting.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_expressions.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_input_types.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_null.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_range.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_timezone.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_week.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_arithmetic.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_calendar.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_date_range.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_expressions.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_input_types.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_null.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_timezone.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_arguments.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_binsize_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_expressions.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_input_types.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_null.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_range.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_timezone.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_truncation.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_week.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/test_dateAdd_dateSubtract_equivalence.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_date_operators.py diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_arithmetic.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_arithmetic.py new file mode 100644 index 000000000..3e5272bde --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_arithmetic.py @@ -0,0 +1,276 @@ +"""$dateAdd core arithmetic: unit application, sign, amount types, and unit equivalence.""" + +from datetime import datetime, timezone + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_NEGATIVE_ZERO, + DOUBLE_NEGATIVE_ZERO, + INT32_MAX, + INT32_MIN, +) + +# Property [Unit Arithmetic]: adding a positive amount advances the start date by that unit. +DATEADD_UNIT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "year_add", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "year", "amount": 1}}, + expected=datetime(2001, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should add 1 year", + ), + ExpressionTestCase( + "month_add", + doc={"date": datetime(2000, 1, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": 2}}, + expected=datetime(2000, 3, 15, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should add 2 months", + ), + ExpressionTestCase( + "day_add", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 10}}, + expected=datetime(2000, 1, 11, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should add 10 days", + ), + ExpressionTestCase( + "hour_add", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "hour", "amount": 5}}, + expected=datetime(2000, 1, 1, 17, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should add 5 hours", + ), + ExpressionTestCase( + "minute_add", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "minute", "amount": 30}}, + expected=datetime(2000, 1, 1, 12, 30, 0, tzinfo=timezone.utc), + msg="$dateAdd should add 30 minutes", + ), + ExpressionTestCase( + "second_add", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "second", "amount": 45}}, + expected=datetime(2000, 1, 1, 12, 0, 45, tzinfo=timezone.utc), + msg="$dateAdd should add 45 seconds", + ), + ExpressionTestCase( + "millisecond_add", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "millisecond", "amount": 500}}, + expected=datetime(2000, 1, 1, 12, 0, 0, 500000, tzinfo=timezone.utc), + msg="$dateAdd should add 500 milliseconds", + ), + ExpressionTestCase( + "week_add", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "week", "amount": 2}}, + expected=datetime(2000, 1, 15, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should add 2 weeks", + ), + ExpressionTestCase( + "quarter_add", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "quarter", "amount": 1}}, + expected=datetime(2000, 4, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should add 1 quarter", + ), +] + +# Property [Negative Amount]: a negative amount subtracts the given unit. +DATEADD_NEGATIVE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "year_subtract", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "year", "amount": -1}}, + expected=datetime(1999, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should subtract 1 year with a negative amount", + ), + ExpressionTestCase( + "month_subtract", + doc={"date": datetime(2000, 3, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": -2}}, + expected=datetime(2000, 1, 15, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should subtract 2 months with a negative amount", + ), + ExpressionTestCase( + "day_subtract", + doc={"date": datetime(2000, 1, 11, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": -10}}, + expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should subtract 10 days with a negative amount", + ), +] + +# Property [Zero Amount]: a zero amount for any unit returns the start date unchanged. +DATEADD_ZERO_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"zero_amount_{unit}", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": unit, "amount": 0}}, + expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg=f"$dateAdd should return the start date unchanged for a zero {unit} amount", + ) + for unit in ( + "year", + "quarter", + "month", + "week", + "day", + "hour", + "minute", + "second", + "millisecond", + ) +] + +# Property [Amount Numeric Types]: integral int64, double, and decimal128 amounts, including +# negative zero, are accepted. +DATEADD_AMOUNT_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "amount_int64", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": Int64(5)}}, + expected=datetime(2000, 1, 6, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should accept an int64 amount", + ), + ExpressionTestCase( + "amount_double_integral", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 5.0}}, + expected=datetime(2000, 1, 6, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should accept an integral double amount", + ), + ExpressionTestCase( + "amount_decimal", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": Decimal128("5")}}, + expected=datetime(2000, 1, 6, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should accept an integral decimal128 amount", + ), + ExpressionTestCase( + "amount_double_negative_zero", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateAdd": {"startDate": "$date", "unit": "day", "amount": DOUBLE_NEGATIVE_ZERO} + }, + expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should treat a negative-zero double as a zero amount", + ), + ExpressionTestCase( + "amount_decimal128_negative_zero", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateAdd": {"startDate": "$date", "unit": "day", "amount": DECIMAL128_NEGATIVE_ZERO} + }, + expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should treat a negative-zero decimal128 as a zero amount", + ), + ExpressionTestCase( + "amount_decimal128_max_precision_integral", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateAdd": { + "startDate": "$date", + "unit": "day", + "amount": Decimal128("3.0000000000000000000000000000000000"), + } + }, + expected=datetime(2000, 1, 4, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should accept a max-precision integral decimal128 amount", + ), +] + +# Property [Amount Boundary]: large valid int32/int64 amounts are accepted. +DATEADD_AMOUNT_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "amount_long_large", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateAdd": {"startDate": "$date", "unit": "second", "amount": Int64(2_200_000_000)} + }, + expected=datetime(2069, 9, 18, 11, 6, 40, tzinfo=timezone.utc), + msg="$dateAdd should accept a large int64 amount", + ), + ExpressionTestCase( + "amount_int32_max", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "millisecond", "amount": INT32_MAX}}, + expected=datetime(2000, 1, 26, 8, 31, 23, 647000, tzinfo=timezone.utc), + msg="$dateAdd should accept INT32_MAX milliseconds", + ), + ExpressionTestCase( + "amount_int32_min", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "millisecond", "amount": INT32_MIN}}, + expected=datetime(1999, 12, 7, 15, 28, 36, 352000, tzinfo=timezone.utc), + msg="$dateAdd should accept INT32_MIN milliseconds", + ), +] + +# Property [Unit Equivalence]: a smaller unit times its multiple equals the larger unit. +DATEADD_UNIT_EQUIVALENCE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "millisecond_1000_equals_second_1", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "millisecond", "amount": 1000}}, + expected=datetime(2000, 1, 1, 12, 0, 1, tzinfo=timezone.utc), + msg="$dateAdd of 1000 milliseconds should equal adding 1 second", + ), + ExpressionTestCase( + "millisecond_precision_1ms", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "millisecond", "amount": 1}}, + expected=datetime(2000, 1, 1, 12, 0, 0, 1000, tzinfo=timezone.utc), + msg="$dateAdd should increment by exactly 1 millisecond", + ), + ExpressionTestCase( + "day_7_equals_week", + doc={"date": datetime(2000, 6, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 7}}, + expected=datetime(2000, 6, 8, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd of 7 days should equal adding 1 week", + ), + ExpressionTestCase( + "month_12_equals_year", + doc={"date": datetime(2000, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": 12}}, + expected=datetime(2001, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd of 12 months should equal adding 1 year", + ), + ExpressionTestCase( + "month_3_equals_quarter", + doc={"date": datetime(2000, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": 3}}, + expected=datetime(2000, 9, 15, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd of 3 months should equal adding 1 quarter", + ), +] + +DATEADD_ARITHMETIC_TESTS: list[ExpressionTestCase] = ( + DATEADD_UNIT_TESTS + + DATEADD_NEGATIVE_TESTS + + DATEADD_ZERO_TESTS + + DATEADD_AMOUNT_TYPE_TESTS + + DATEADD_AMOUNT_BOUNDARY_TESTS + + DATEADD_UNIT_EQUIVALENCE_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATEADD_ARITHMETIC_TESTS)) +def test_dateAdd_arithmetic(collection, test_case: ExpressionTestCase): + """Test $dateAdd arithmetic across units, amount signs, and amount types.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_calendar.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_calendar.py new file mode 100644 index 000000000..935dae4c8 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_calendar.py @@ -0,0 +1,246 @@ +"""$dateAdd calendar rules: leap years, century leap rule, month clamping, and boundary carry.""" + +from datetime import datetime, timezone + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DATE_Y2K, +) + +# Property [Leap Year]: adding across February resolves leap-year day counts correctly. +DATEADD_LEAP_YEAR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "leap_year_feb29_add_year", + doc={"date": datetime(2000, 2, 29, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "year", "amount": 1}}, + expected=datetime(2001, 2, 28, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should clamp Feb 29 to Feb 28 when adding a year into a non-leap year", + ), + ExpressionTestCase( + "leap_year_add_day_to_feb28", + doc={"date": datetime(2000, 2, 28, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=datetime(2000, 2, 29, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should land on Feb 29 in a leap year", + ), + ExpressionTestCase( + "non_leap_year_feb28_add_day", + doc={"date": datetime(1999, 2, 28, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=datetime(1999, 3, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should roll to Mar 1 in a non-leap year", + ), + ExpressionTestCase( + "leap_year_feb29_add_month", + doc={"date": datetime(2020, 2, 29, 10, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": 1}}, + expected=datetime(2020, 3, 29, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should add a month from Feb 29 to Mar 29", + ), + ExpressionTestCase( + "leap_year_feb27_add_2days", + doc={"date": datetime(2020, 2, 27, 14, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 2}}, + expected=datetime(2020, 2, 29, 14, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should land on Feb 29 in a leap year", + ), + ExpressionTestCase( + "non_leap_year_feb27_add_2days", + doc={"date": datetime(2021, 2, 27, 14, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 2}}, + expected=datetime(2021, 3, 1, 14, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should roll to Mar 1 in a non-leap year", + ), + ExpressionTestCase( + "leap_year_365_days", + doc={"date": datetime(2020, 1, 1, 15, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 365}}, + expected=datetime(2020, 12, 31, 15, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should not wrap the year when adding 365 days in a leap year", + ), + ExpressionTestCase( + "non_leap_year_365_days", + doc={"date": datetime(2019, 1, 1, 15, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 365}}, + expected=datetime(2020, 1, 1, 15, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should wrap to the next year when adding 365 days in a non-leap year", + ), +] + +# Property [Century Leap Year]: the divisible-by-100/400 leap rule is honored. +DATEADD_CENTURY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "century_non_leap_1900_feb28_add_day", + doc={"date": datetime(1900, 2, 28, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=datetime(1900, 3, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should roll to Mar 1 in 1900, a non-leap century year", + ), + ExpressionTestCase( + "century_leap_2000_feb28_add_day", + doc={"date": datetime(2000, 2, 28, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=datetime(2000, 2, 29, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should land on Feb 29 in 2000, a leap century year", + ), +] + +# Property [Month Clamping]: adding months or quarters clamps to the last valid day of the +# target month. +DATEADD_MONTH_CLAMP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "jan31_add_month_leap", + doc={"date": datetime(2020, 1, 31, 10, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": 1}}, + expected=datetime(2020, 2, 29, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should clamp Jan 31 plus 1 month to Feb 29 in a leap year", + ), + ExpressionTestCase( + "jan31_add_month_non_leap", + doc={"date": datetime(2021, 1, 31, 10, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": 1}}, + expected=datetime(2021, 2, 28, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should clamp Jan 31 plus 1 month to Feb 28 in a non-leap year", + ), + ExpressionTestCase( + "jan29_add_month_non_leap", + doc={"date": datetime(2021, 1, 29, 10, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": 1}}, + expected=datetime(2021, 2, 28, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should clamp Jan 29 plus 1 month to Feb 28 in a non-leap year", + ), + ExpressionTestCase( + "oct31_add_month", + doc={"date": datetime(2020, 10, 31, 10, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": 1}}, + expected=datetime(2020, 11, 30, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should clamp Oct 31 plus 1 month to Nov 30", + ), + ExpressionTestCase( + "mar31_add_month", + doc={"date": datetime(2000, 3, 31, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": 1}}, + expected=datetime(2000, 4, 30, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should clamp Mar 31 plus 1 month to Apr 30", + ), + ExpressionTestCase( + "may31_add_month", + doc={"date": datetime(2000, 5, 31, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": 1}}, + expected=datetime(2000, 6, 30, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should clamp May 31 plus 1 month to Jun 30", + ), + ExpressionTestCase( + "aug31_add_month", + doc={"date": datetime(2020, 8, 31, 10, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": 1}}, + expected=datetime(2020, 9, 30, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should clamp Aug 31 plus 1 month to Sep 30", + ), + ExpressionTestCase( + "dec31_add_year_no_adjustment", + doc={"date": datetime(2020, 12, 31, 10, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "year", "amount": 1}}, + expected=datetime(2021, 12, 31, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should not adjust Dec 31 when adding a year", + ), + ExpressionTestCase( + "mar31_subtract_month_leap", + doc={"date": datetime(2020, 3, 31, 10, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": -1}}, + expected=datetime(2020, 2, 29, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should clamp Mar 31 minus 1 month to Feb 29 in a leap year", + ), + ExpressionTestCase( + "mar31_subtract_month_non_leap", + doc={"date": datetime(2021, 3, 31, 10, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": -1}}, + expected=datetime(2021, 2, 28, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should clamp Mar 31 minus 1 month to Feb 28 in a non-leap year", + ), + ExpressionTestCase( + "jan31_subtract_month_no_adjustment", + doc={"date": datetime(2021, 1, 31, 10, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": -1}}, + expected=datetime(2020, 12, 31, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should not adjust Jan 31 minus 1 month, landing on Dec 31", + ), + ExpressionTestCase( + "jan31_add_quarter", + doc={"date": datetime(2021, 1, 31, 10, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "quarter", "amount": 1}}, + expected=datetime(2021, 4, 30, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should clamp Jan 31 plus 1 quarter to Apr 30", + ), + ExpressionTestCase( + "quarter_subtract", + doc={"date": datetime(2021, 4, 30, 10, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "quarter", "amount": -1}}, + expected=datetime(2021, 1, 30, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should subtract 1 quarter", + ), + ExpressionTestCase( + "large_positive_month", + doc={"date": datetime(2020, 12, 31, 12, 10, 5, 10000, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": 49}}, + expected=datetime(2025, 1, 31, 12, 10, 5, 10000, tzinfo=timezone.utc), + msg="$dateAdd should clamp a large positive month amount to end-of-month", + ), + ExpressionTestCase( + "large_negative_month", + doc={"date": datetime(2020, 12, 31, 12, 10, 5, 10000, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": -49}}, + expected=datetime(2016, 11, 30, 12, 10, 5, 10000, tzinfo=timezone.utc), + msg="$dateAdd should clamp a large negative month amount to end-of-month", + ), +] + +# Property [Boundary Crossing]: adding across day, month, and year boundaries carries correctly. +DATEADD_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "dec31_add_day", + doc={"date": datetime(2000, 12, 31, 23, 59, 59, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=datetime(2001, 1, 1, 23, 59, 59, tzinfo=timezone.utc), + msg="$dateAdd should cross the year boundary from Dec 31 plus 1 day", + ), + ExpressionTestCase( + "jan1_subtract_day", + doc={"date": DATE_Y2K}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": -1}}, + expected=datetime(1999, 12, 31, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should cross the year boundary from Jan 1 minus 1 day", + ), + ExpressionTestCase( + "dec31_add_hour", + doc={"date": datetime(2000, 12, 31, 23, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "hour", "amount": 2}}, + expected=datetime(2001, 1, 1, 1, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should cross the year boundary from Dec 31 plus 2 hours", + ), +] + +DATEADD_CALENDAR_TESTS: list[ExpressionTestCase] = ( + DATEADD_LEAP_YEAR_TESTS + + DATEADD_CENTURY_TESTS + + DATEADD_MONTH_CLAMP_TESTS + + DATEADD_BOUNDARY_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATEADD_CALENDAR_TESTS)) +def test_dateAdd_calendar(collection, test_case: ExpressionTestCase): + """Test $dateAdd honors calendar rules when adding months, years, and across boundaries.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_date_range.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_date_range.py new file mode 100644 index 000000000..fee91defc --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_date_range.py @@ -0,0 +1,130 @@ +"""$dateAdd across the representable date range: historical, future, epoch, and limit dates.""" + +from datetime import datetime, timezone + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DATE_EPOCH, + DATE_YEAR_1900, + DATE_YEAR_9999, +) + +# Property [Historical And Future]: distant past and future start dates are handled. +DATEADD_HISTORICAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "historical_date", + doc={"date": datetime(1900, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "year", "amount": 10}}, + expected=datetime(1910, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should handle a 1900 historical date", + ), + ExpressionTestCase( + "pre_epoch_1960", + doc={"date": datetime(1960, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 100}}, + expected=datetime(1960, 4, 10, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should handle a pre-epoch 1960 date", + ), + ExpressionTestCase( + "far_future", + doc={"date": datetime(2100, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "year", "amount": 100}}, + expected=datetime(2200, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should handle a far-future 2100 date", + ), + ExpressionTestCase( + "large_year_amount", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "year", "amount": 1000}}, + expected=datetime(3000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should handle adding 1000 years", + ), + ExpressionTestCase( + "distant_past", + doc={"date": DATE_YEAR_1900}, + expression={"$dateAdd": {"startDate": "$date", "unit": "year", "amount": 1}}, + expected=datetime(1901, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should handle 1900 plus 1 year", + ), + ExpressionTestCase( + "distant_future_month", + doc={"date": datetime(2100, 1, 1, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": 6}}, + expected=datetime(2100, 7, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should handle 2100 plus 6 months", + ), +] + +# Property [Epoch Crossing]: adding forward and backward across the Unix epoch is correct. +DATEADD_EPOCH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "epoch_add_day", + doc={"date": DATE_EPOCH}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=datetime(1970, 1, 2, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should add a day to the epoch", + ), + ExpressionTestCase( + "pre_epoch_add_day", + doc={"date": datetime(1969, 12, 31, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=DATE_EPOCH, + msg="$dateAdd should cross the epoch forward from 1969", + ), + ExpressionTestCase( + "cross_epoch_back", + doc={"date": DATE_EPOCH}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": -1}}, + expected=datetime(1969, 12, 31, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should cross the epoch backward to 1969", + ), +] + +# Property [Date Limits]: adding near the maximum representable date is handled. +DATEADD_DATE_LIMIT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "near_max_date", + doc={"date": datetime(9999, 12, 31, 23, 59, 59, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "millisecond", "amount": 1}}, + expected=datetime(9999, 12, 31, 23, 59, 59, 1000, tzinfo=timezone.utc), + msg="$dateAdd should add 1 millisecond near the maximum date", + ), + ExpressionTestCase( + "epoch_plus_large_ms", + doc={"date": DATE_EPOCH}, + expression={ + "$dateAdd": {"startDate": "$date", "unit": "millisecond", "amount": 253_402_300_799_999} + }, + expected=DATE_YEAR_9999, + msg="$dateAdd should reach the near-maximum date from the epoch with a large ms amount", + ), + ExpressionTestCase( + "at_python_max_year", + doc={"date": datetime(9999, 12, 31, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "hour", "amount": 23}}, + expected=datetime(9999, 12, 31, 23, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should add 23 hours at year 9999", + ), +] + +DATEADD_DATE_RANGE_TESTS: list[ExpressionTestCase] = ( + DATEADD_HISTORICAL_TESTS + DATEADD_EPOCH_TESTS + DATEADD_DATE_LIMIT_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATEADD_DATE_RANGE_TESTS)) +def test_dateAdd_date_range(collection, test_case: ExpressionTestCase): + """Test $dateAdd remains correct across distant, epoch-crossing, and boundary dates.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_errors.py new file mode 100644 index 000000000..566125fe2 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_errors.py @@ -0,0 +1,416 @@ +"""$dateAdd rejection cases: invalid operand types/values, bad timezone, overflow, and shape.""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + DATEADD_INVALID_AMOUNT_ERROR, + DATEADD_INVALID_LARGE_VALUE_ERROR, + DATEADD_INVALID_STARTDATE_ERROR, + DATEADD_MISSING_FIELD_ERROR, + DATEADD_UNKNOWN_FIELD_ERROR, + FAILED_TO_PARSE_ERROR, + INVALID_DATE_UNIT_ERROR, + INVALID_TIMEZONE_ERROR, + INVALID_TIMEZONE_TYPE_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_ONE_AND_HALF, + DOUBLE_MIN_SUBNORMAL, + DOUBLE_NEAR_MIN, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, +) + +# Property [Amount Non-Integral]: a non-integral numeric amount is rejected. +DATEADD_AMOUNT_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "amount_non_integral_double_1_5", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1.5}}, + error_code=DATEADD_INVALID_AMOUNT_ERROR, + msg="$dateAdd should reject a non-integral double amount", + ), + ExpressionTestCase( + "amount_non_integral_double_5_9", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 5.9}}, + error_code=DATEADD_INVALID_AMOUNT_ERROR, + msg="$dateAdd should reject a non-integral double amount close to an integer", + ), + ExpressionTestCase( + "amount_non_integral_double_negative", + doc={"date": datetime(2000, 1, 10, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": -5.9}}, + error_code=DATEADD_INVALID_AMOUNT_ERROR, + msg="$dateAdd should reject a non-integral negative double amount", + ), + ExpressionTestCase( + "amount_non_integral_decimal128", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateAdd": {"startDate": "$date", "unit": "day", "amount": DECIMAL128_ONE_AND_HALF} + }, + error_code=DATEADD_INVALID_AMOUNT_ERROR, + msg="$dateAdd should reject a non-integral decimal128 amount", + ), + ExpressionTestCase( + "amount_decimal128_non_integral_34th_digit", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateAdd": { + "startDate": "$date", + "unit": "day", + "amount": Decimal128("3.000000000000000000000000000000001"), + } + }, + error_code=DATEADD_INVALID_AMOUNT_ERROR, + msg="$dateAdd should reject a decimal128 amount that is non-integral at the 34th digit", + ), + ExpressionTestCase( + "amount_double_near_min", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": DOUBLE_NEAR_MIN}}, + error_code=DATEADD_INVALID_AMOUNT_ERROR, + msg="$dateAdd should reject a near-minimum double amount as non-integral", + ), + ExpressionTestCase( + "amount_double_min_subnormal", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateAdd": {"startDate": "$date", "unit": "day", "amount": DOUBLE_MIN_SUBNORMAL} + }, + error_code=DATEADD_INVALID_AMOUNT_ERROR, + msg="$dateAdd should reject a minimum subnormal double amount as non-integral", + ), +] + +# Property [StartDate Type]: a non-date, non-Timestamp, non-ObjectId startDate is rejected. +DATEADD_STARTDATE_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"startDate_{tid}", + doc={"date": val}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1}}, + error_code=DATEADD_INVALID_STARTDATE_ERROR, + msg=f"$dateAdd should reject a {tid} startDate", + ) + for tid, val in [ + ("string", "2000-01-01"), + ("int", 123_456_789), + ("int64", Int64(123_456_789)), + ("double", 1.5), + ("decimal128", Decimal128("1")), + ("boolean", True), + ("array", [2000, 1, 1]), + ("empty_array", []), + ("single_date_array", [datetime(2021, 6, 15, tzinfo=timezone.utc)]), + ("object", {"year": 2000}), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*")), + ("javascript", Code("function() {}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Amount Type]: a non-numeric amount is rejected. +DATEADD_AMOUNT_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"amount_{tid}", + expression={ + "$dateAdd": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "unit": "day", + "amount": val, + } + }, + error_code=DATEADD_INVALID_AMOUNT_ERROR, + msg=f"$dateAdd should reject a {tid} amount", + ) + for tid, val in [ + ("string", "5"), + ("boolean", True), + ("array", [5]), + ("object", {"value": 5}), + ("datetime", datetime(2000, 1, 1, tzinfo=timezone.utc)), + ("objectid", ObjectId("600000000000000000000000")), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*")), + ("javascript", Code("function() {}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Amount Non-Finite]: a NaN or infinite numeric amount is rejected. +DATEADD_AMOUNT_NONFINITE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"amount_{tid}", + expression={ + "$dateAdd": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "unit": "day", + "amount": val, + } + }, + error_code=DATEADD_INVALID_AMOUNT_ERROR, + msg=f"$dateAdd should reject a {tid} amount", + ) + for tid, val in [ + ("nan", FLOAT_NAN), + ("decimal128_nan", DECIMAL128_NAN), + ("infinity", FLOAT_INFINITY), + ("neg_infinity", FLOAT_NEGATIVE_INFINITY), + ("decimal128_infinity", DECIMAL128_INFINITY), + ("decimal128_neg_infinity", DECIMAL128_NEGATIVE_INFINITY), + ] +] + +# Property [Unit Type]: a non-string unit is rejected as an invalid date unit. +DATEADD_UNIT_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"unit_{tid}", + expression={ + "$dateAdd": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "unit": val, + "amount": 5, + } + }, + error_code=INVALID_DATE_UNIT_ERROR, + msg=f"$dateAdd should reject a {tid} unit", + ) + for tid, val in [ + ("number", 1), + ("int64", Int64(1)), + ("double", 1.0), + ("decimal128", Decimal128("1")), + ("boolean", True), + ("array", ["day"]), + ("object", {"type": "day"}), + ("datetime", datetime(2000, 1, 1, tzinfo=timezone.utc)), + ("objectid", ObjectId("600000000000000000000000")), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex("day")), + ("javascript", Code("function() {}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Unit String]: an unrecognized unit string, including wrong case and plurals, is +# rejected at parse time. +DATEADD_UNIT_STRING_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"unit_{tid}", + expression={ + "$dateAdd": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "unit": val, + "amount": 5, + } + }, + error_code=FAILED_TO_PARSE_ERROR, + msg=f"$dateAdd should reject the {desc}", + ) + for tid, val, desc in [ + ("invalid_string", "invalid", "unrecognized unit string"), + ("empty_string", "", "empty string unit"), + ("mixed_case_Day", "Day", "mixed-case unit Day"), + ("mixed_case_Hour", "Hour", "mixed-case unit Hour"), + ("mixed_case_Month", "Month", "mixed-case unit Month"), + ("mixed_case_Quarter", "Quarter", "mixed-case unit Quarter"), + ("mixed_case_Week", "Week", "mixed-case unit Week"), + ("mixed_case_Second", "Second", "mixed-case unit Second"), + ("mixed_case_Minute", "Minute", "mixed-case unit Minute"), + ("mixed_case_Millisecond", "Millisecond", "mixed-case unit Millisecond"), + ("mixed_case_Year", "Year", "mixed-case unit Year"), + ("uppercase_DAY", "DAY", "uppercase unit DAY"), + ("uppercase_MILLISECOND", "MILLISECOND", "uppercase unit MILLISECOND"), + ("uppercase_YEAR", "YEAR", "uppercase unit YEAR"), + ("uppercase_HOUR", "HOUR", "uppercase unit HOUR"), + ("uppercase_MONTH", "MONTH", "uppercase unit MONTH"), + ("uppercase_QUARTER", "QUARTER", "uppercase unit QUARTER"), + ("uppercase_WEEK", "WEEK", "uppercase unit WEEK"), + ("uppercase_SECOND", "SECOND", "uppercase unit SECOND"), + ("uppercase_MINUTE", "MINUTE", "uppercase unit MINUTE"), + ("plural_years", "years", "plural unit years"), + ("plural_days", "days", "plural unit days"), + ("plural_months", "months", "plural unit months"), + ("plural_hours", "hours", "plural unit hours"), + ("plural_minutes", "minutes", "plural unit minutes"), + ("plural_seconds", "seconds", "plural unit seconds"), + ("plural_milliseconds", "milliseconds", "plural unit milliseconds"), + ("plural_weeks", "weeks", "plural unit weeks"), + ("plural_quarters", "quarters", "plural unit quarters"), + ("epoch_invalid", "epoch", "invalid unit epoch"), + ] +] + +# Property [Invalid Timezone]: an unrecognized or malformed timezone string is rejected. +DATEADD_TIMEZONE_INVALID_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"timezone_{tid}", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateAdd": {"startDate": "$date", "unit": "hour", "amount": 5, "timezone": tz} + }, + error_code=INVALID_TIMEZONE_ERROR, + msg=f"$dateAdd should reject {desc}", + ) + for tid, tz, desc in [ + ("offset_3digit_hours_invalid", "+100:00", "a 3-digit hour offset"), + ("invalid", "Invalid/Timezone", "an unrecognized Olson timezone"), + ("empty_string", "", "an empty string timezone"), + ("olson_wrong_case_lowercase", "america/new_york", "an all-lowercase Olson name"), + ("olson_wrong_case_uppercase", "AMERICA/NEW_YORK", "an all-uppercase Olson name"), + ("olson_wrong_case_mixed", "america/New_York", "a mixed-case Olson name"), + ] +] + +# Property [Timezone Type]: a non-string timezone is rejected as an invalid type. +DATEADD_TIMEZONE_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"timezone_{tid}", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateAdd": {"startDate": "$date", "unit": "hour", "amount": 5, "timezone": tz} + }, + error_code=INVALID_TIMEZONE_TYPE_ERROR, + msg=f"$dateAdd should reject a {tid} timezone", + ) + for tid, tz in [ + ("number", 5), + ("boolean", True), + ("array", ["UTC"]), + ("object", {"tz": "UTC"}), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Overflow]: an amount that pushes the result beyond the representable date range +# is rejected. +DATEADD_OVERFLOW_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "large_negative_month_overflow", + doc={"date": datetime(2020, 12, 31, 12, 10, 5, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": -30_000_000_000}}, + error_code=DATEADD_INVALID_LARGE_VALUE_ERROR, + msg="$dateAdd should reject a month amount that overflows the representable date range", + ), +] + +# Property [Array-Resolving Path]: a startDate field path that resolves to an array is rejected +# by the operator's startDate type contract. +DATEADD_ARRAY_PATH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "composite_array_path", + doc={ + "a": [ + {"b": datetime(2021, 6, 15, tzinfo=timezone.utc)}, + {"b": datetime(2021, 7, 1, tzinfo=timezone.utc)}, + ] + }, + expression={"$dateAdd": {"startDate": "$a.b", "unit": "day", "amount": 1}}, + error_code=DATEADD_INVALID_STARTDATE_ERROR, + msg="$dateAdd should reject a composite array field path as startDate", + ), + ExpressionTestCase( + "single_element_array_path", + doc={"a": [{"b": datetime(2021, 6, 15, tzinfo=timezone.utc)}]}, + expression={"$dateAdd": {"startDate": "$a.b", "unit": "day", "amount": 1}}, + error_code=DATEADD_INVALID_STARTDATE_ERROR, + msg="$dateAdd should reject a single-element array field path as startDate", + ), +] + +# Property [Argument Shape]: a missing required field or an unknown field is rejected. +DATEADD_ARGUMENT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "arg_missing_startDate", + expression={"$dateAdd": {"unit": "day", "amount": 1}}, + error_code=DATEADD_MISSING_FIELD_ERROR, + msg="$dateAdd should error when startDate is missing", + ), + ExpressionTestCase( + "arg_missing_unit", + expression={ + "$dateAdd": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "amount": 1, + } + }, + error_code=DATEADD_MISSING_FIELD_ERROR, + msg="$dateAdd should error when unit is missing", + ), + ExpressionTestCase( + "arg_missing_amount", + expression={ + "$dateAdd": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "unit": "day", + } + }, + error_code=DATEADD_MISSING_FIELD_ERROR, + msg="$dateAdd should error when amount is missing", + ), + ExpressionTestCase( + "arg_empty_object", + expression={"$dateAdd": {}}, + error_code=DATEADD_MISSING_FIELD_ERROR, + msg="$dateAdd should error for an empty argument object", + ), + ExpressionTestCase( + "arg_unknown_field", + expression={ + "$dateAdd": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "unit": "day", + "amount": 1, + "foo": 1, + } + }, + error_code=DATEADD_UNKNOWN_FIELD_ERROR, + msg="$dateAdd should error for an unknown field", + ), +] + +DATEADD_ERROR_TESTS: list[ExpressionTestCase] = ( + DATEADD_AMOUNT_ERROR_TESTS + + DATEADD_STARTDATE_TYPE_ERROR_TESTS + + DATEADD_AMOUNT_TYPE_ERROR_TESTS + + DATEADD_AMOUNT_NONFINITE_ERROR_TESTS + + DATEADD_UNIT_TYPE_ERROR_TESTS + + DATEADD_UNIT_STRING_ERROR_TESTS + + DATEADD_TIMEZONE_INVALID_TESTS + + DATEADD_TIMEZONE_TYPE_ERROR_TESTS + + DATEADD_OVERFLOW_ERROR_TESTS + + DATEADD_ARRAY_PATH_TESTS + + DATEADD_ARGUMENT_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATEADD_ERROR_TESTS)) +def test_dateAdd_errors(collection, test_case: ExpressionTestCase): + """Test $dateAdd rejects invalid operands, timezones, overflow, and argument shapes.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_expressions.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_expressions.py new file mode 100644 index 000000000..6a8753256 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_expressions.py @@ -0,0 +1,89 @@ +"""$dateAdd operand evaluation: literal operands and field-reference resolution.""" + +from datetime import datetime, timezone + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Literal Input]: $dateAdd evaluates literal operands. +DATEADD_LITERAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "literal_year_add", + expression={ + "$dateAdd": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "unit": "year", + "amount": 1, + } + }, + expected=datetime(2001, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should add 1 year from literal operands", + ), + ExpressionTestCase( + "literal_day_add", + expression={ + "$dateAdd": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "unit": "day", + "amount": 10, + } + }, + expected=datetime(2000, 1, 11, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should add 10 days from literal operands", + ), + ExpressionTestCase( + "literal_null_startDate", + expression={"$dateAdd": {"startDate": None, "unit": "day", "amount": 1}}, + expected=None, + msg="$dateAdd should return null for a literal null startDate", + ), +] + +# Property [Field Reference Operands]: the amount and unit operands resolve from field references. +DATEADD_FIELD_REF_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "field_ref_amount", + doc={"amt": 5}, + expression={ + "$dateAdd": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "unit": "day", + "amount": "$amt", + } + }, + expected=datetime(2000, 1, 6, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should resolve the amount from a field reference", + ), + ExpressionTestCase( + "field_ref_unit", + doc={"unit_field": "day"}, + expression={ + "$dateAdd": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "unit": "$unit_field", + "amount": 5, + } + }, + expected=datetime(2000, 1, 6, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should resolve the unit from a field reference", + ), +] + +DATEADD_EXPRESSION_TESTS: list[ExpressionTestCase] = DATEADD_LITERAL_TESTS + DATEADD_FIELD_REF_TESTS + + +@pytest.mark.parametrize("test_case", pytest_params(DATEADD_EXPRESSION_TESTS)) +def test_dateAdd_expressions(collection, test_case: ExpressionTestCase): + """Test $dateAdd evaluates literal and field-reference operands.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_input_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_input_types.py new file mode 100644 index 000000000..69e507585 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_input_types.py @@ -0,0 +1,160 @@ +"""$dateAdd date-like input types: Timestamp and ObjectId start dates always return a Date.""" + +from datetime import datetime, timezone + +import pytest +from bson import ObjectId, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.date_utils import ( + oid_from_args, + ts_from_args, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DATE_MS_EPOCH, + OID_EPOCH, + OID_MAX_SIGNED32, + OID_MIN_SIGNED32, + TS_EPOCH, + TS_MAX_SIGNED32, + TS_MAX_UNSIGNED32, +) + +# Property [Timestamp Start Date]: a Timestamp or DatetimeMS start date is accepted and returns +# a Date. +DATEADD_TIMESTAMP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "timestamp_startDate_day", + doc={"date": ts_from_args(2020, 12, 31, 12, 10, 5)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=datetime(2021, 1, 1, 12, 10, 5, tzinfo=timezone.utc), + msg="$dateAdd should accept a Timestamp start date and return a Date", + ), + ExpressionTestCase( + "timestamp_startDate_second", + doc={"date": ts_from_args(2000, 1, 1, 12, 0, 0)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "second", "amount": 1}}, + expected=datetime(2000, 1, 1, 12, 0, 1, tzinfo=timezone.utc), + msg="$dateAdd should add a second to a Timestamp start date", + ), + ExpressionTestCase( + "timestamp_epoch", + doc={"date": TS_EPOCH}, + expression={"$dateAdd": {"startDate": "$date", "unit": "second", "amount": 1}}, + expected=datetime(1970, 1, 1, 0, 0, 1, tzinfo=timezone.utc), + msg="$dateAdd should accept an epoch Timestamp start date", + ), + ExpressionTestCase( + "date_ms_epoch_add_day", + doc={"date": DATE_MS_EPOCH}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=datetime(1970, 1, 2, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should add a day to an epoch DatetimeMS start date", + ), + ExpressionTestCase( + "ts_max_s32_add_second", + doc={"date": TS_MAX_SIGNED32}, + expression={"$dateAdd": {"startDate": "$date", "unit": "second", "amount": 1}}, + expected=datetime(2038, 1, 19, 3, 14, 8, tzinfo=timezone.utc), + msg="$dateAdd should add a second to a max signed 32-bit Timestamp", + ), + ExpressionTestCase( + "ts_max_u32_add_second", + doc={"date": TS_MAX_UNSIGNED32}, + expression={"$dateAdd": {"startDate": "$date", "unit": "second", "amount": 1}}, + expected=datetime(2106, 2, 7, 6, 28, 16, tzinfo=timezone.utc), + msg="$dateAdd should add a second to a max unsigned 32-bit Timestamp", + ), +] + +# Property [ObjectId Start Date]: an ObjectId start date uses its embedded timestamp and +# returns a Date. +DATEADD_OBJECTID_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "objectid_startDate_day", + doc={"date": oid_from_args(2022, 7, 15, 22, 32, 25)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "year", "amount": 1}}, + expected=datetime(2023, 7, 15, 22, 32, 25, tzinfo=timezone.utc), + msg="$dateAdd should accept an ObjectId start date and return a Date", + ), + ExpressionTestCase( + "objectid_startDate_second", + doc={"date": oid_from_args(2022, 7, 15, 22, 32, 25)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "second", "amount": 1}}, + expected=datetime(2022, 7, 15, 22, 32, 26, tzinfo=timezone.utc), + msg="$dateAdd should add a second to an ObjectId start date", + ), + ExpressionTestCase( + "objectid_startDate_millisecond", + doc={"date": oid_from_args(2022, 7, 15, 22, 32, 25)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "millisecond", "amount": 500}}, + expected=datetime(2022, 7, 15, 22, 32, 25, 500000, tzinfo=timezone.utc), + msg="$dateAdd should add sub-second precision to an ObjectId start date", + ), + ExpressionTestCase( + "objectid_epoch", + doc={"date": OID_EPOCH}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=datetime(1970, 1, 2, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should accept an epoch ObjectId start date", + ), + ExpressionTestCase( + "objectid_max_signed32", + doc={"date": OID_MAX_SIGNED32}, + expression={"$dateAdd": {"startDate": "$date", "unit": "second", "amount": 1}}, + expected=datetime(2038, 1, 19, 3, 14, 8, tzinfo=timezone.utc), + msg="$dateAdd should add a second to a max signed 32-bit ObjectId", + ), + ExpressionTestCase( + "objectid_high_bit_pre_epoch", + doc={"date": OID_MIN_SIGNED32}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=datetime(1901, 12, 14, 20, 45, 52, tzinfo=timezone.utc), + msg="$dateAdd should handle an ObjectId with the high timestamp bit set", + ), +] + +# Property [Return Type]: $dateAdd returns a Date regardless of the start date's date-like type. +DATEADD_RETURN_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "return_type_from_date", + doc={"date": datetime(2021, 1, 1, tzinfo=timezone.utc)}, + expression={"$type": {"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1}}}, + expected="date", + msg="$dateAdd should return a Date from a Date start date", + ), + ExpressionTestCase( + "return_type_from_timestamp", + doc={"date": Timestamp(1609459200, 1)}, + expression={"$type": {"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1}}}, + expected="date", + msg="$dateAdd should return a Date from a Timestamp start date", + ), + ExpressionTestCase( + "return_type_from_objectid", + doc={"date": ObjectId("600000000000000000000000")}, + expression={"$type": {"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1}}}, + expected="date", + msg="$dateAdd should return a Date from an ObjectId start date", + ), +] + +DATEADD_INPUT_TYPE_TESTS: list[ExpressionTestCase] = ( + DATEADD_TIMESTAMP_TESTS + DATEADD_OBJECTID_TESTS + DATEADD_RETURN_TYPE_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATEADD_INPUT_TYPE_TESTS)) +def test_dateAdd_input_types(collection, test_case: ExpressionTestCase): + """Test $dateAdd accepts date-like input types and always returns a Date.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_null.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_null.py new file mode 100644 index 000000000..1461d9bed --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_null.py @@ -0,0 +1,138 @@ +"""$dateAdd null and missing propagation: a null or missing operand returns null.""" + +from datetime import datetime, timezone + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + MISSING, +) + +# Property [Null Handling]: a null literal for startDate, amount, or unit returns null. +DATEADD_NULL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_startDate", + doc={"date": None}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=None, + msg="$dateAdd should return null for a null startDate", + ), + ExpressionTestCase( + "null_amount", + expression={ + "$dateAdd": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "unit": "day", + "amount": None, + } + }, + expected=None, + msg="$dateAdd should return null for a null amount", + ), + ExpressionTestCase( + "null_unit", + expression={ + "$dateAdd": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "unit": None, + "amount": 1, + } + }, + expected=None, + msg="$dateAdd should return null for a null unit", + ), + ExpressionTestCase( + "null_startDate_zero_amount", + doc={"date": None}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 0}}, + expected=None, + msg="$dateAdd should return null for a null startDate even with a zero amount", + ), + ExpressionTestCase( + "all_null", + expression={"$dateAdd": {"startDate": None, "unit": None, "amount": None}}, + expected=None, + msg="$dateAdd should return null when all inputs are null", + ), +] + +# Property [Missing Field Reference]: a missing startDate, amount, unit, or timezone field +# reference returns null. +DATEADD_MISSING_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "missing_startDate", + expression={"$dateAdd": {"startDate": MISSING, "unit": "day", "amount": 1}}, + expected=None, + msg="$dateAdd should return null for a missing startDate field reference", + ), + ExpressionTestCase( + "missing_amount", + expression={ + "$dateAdd": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "unit": "day", + "amount": MISSING, + } + }, + expected=None, + msg="$dateAdd should return null for a missing amount field reference", + ), + ExpressionTestCase( + "missing_unit", + expression={ + "$dateAdd": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "unit": MISSING, + "amount": 1, + } + }, + expected=None, + msg="$dateAdd should return null for a missing unit field reference", + ), + ExpressionTestCase( + "missing_startDate_zero_amount", + expression={"$dateAdd": {"startDate": MISSING, "unit": "day", "amount": 0}}, + expected=None, + msg="$dateAdd should return null for a missing startDate field reference and zero amount", + ), + ExpressionTestCase( + "missing_timezone", + expression={ + "$dateAdd": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "unit": "day", + "amount": 1, + "timezone": MISSING, + } + }, + expected=None, + msg="$dateAdd should return null for a missing timezone field reference", + ), + ExpressionTestCase( + "missing_startDate_with_tz", + expression={ + "$dateAdd": {"startDate": MISSING, "unit": "day", "amount": 1, "timezone": "UTC"} + }, + expected=None, + msg="$dateAdd should return null for a missing startDate even with a timezone", + ), +] + +DATEADD_NULL_MISSING_TESTS: list[ExpressionTestCase] = DATEADD_NULL_TESTS + DATEADD_MISSING_TESTS + + +@pytest.mark.parametrize("test_case", pytest_params(DATEADD_NULL_MISSING_TESTS)) +def test_dateAdd_null(collection, test_case: ExpressionTestCase): + """Test $dateAdd returns null for null literals and missing field references.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_timezone.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_timezone.py new file mode 100644 index 000000000..cf51468db --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_timezone.py @@ -0,0 +1,318 @@ +"""$dateAdd timezone handling: Olson ids, UTC offsets, DST transitions, and field references.""" + +from datetime import datetime, timezone + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Olson Timezone]: a valid Olson timezone id is accepted. +DATEADD_TIMEZONE_OLSON_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"timezone_{tid}", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateAdd": {"startDate": "$date", "unit": "hour", "amount": 5, "timezone": tz} + }, + expected=datetime(2000, 1, 1, 17, 0, 0, tzinfo=timezone.utc), + msg=f"$dateAdd should accept the {tz} timezone", + ) + for tid, tz in [ + ("utc", "UTC"), + ("gmt", "GMT"), + ("america_ny", "America/New_York"), + ("europe_london", "Europe/London"), + ("asia_tokyo", "Asia/Tokyo"), + ("asia_kolkata", "Asia/Kolkata"), + ("pacific_apia", "Pacific/Apia"), + ] +] + +# Property [UTC Offset]: syntactically valid UTC offset strings are accepted, including +# numerically out-of-range but well-formed offsets. +DATEADD_TIMEZONE_OFFSET_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"timezone_offset_{tid}", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateAdd": {"startDate": "$date", "unit": "hour", "amount": 5, "timezone": tz} + }, + expected=datetime(2000, 1, 1, 17, 0, 0, tzinfo=timezone.utc), + msg=f"$dateAdd should accept the {tz} UTC offset", + ) + for tid, tz in [ + ("colon_positive", "+05:30"), + ("no_colon", "-0530"), + ("hour_only", "+03"), + ("zero", "+00:00"), + ("max_east", "+14:00"), + ("max_west", "-11:00"), + ("half_hour_west", "-02:30"), + ("45min", "+05:45"), + ("over60_minutes_positive", "+05:70"), + ("over60_minutes_negative", "-05:70"), + ("over24_hours_positive", "+25:00"), + ("over24_hours_negative", "-25:00"), + ("max_valid_positive", "+99:99"), + ("max_valid_negative", "-99:99"), + ("out_of_range_east", "+15:00"), + ("out_of_range_west", "-13:00"), + ] +] + +# Property [DST Transition]: day and larger units track wall-clock time across DST, while +# 24-hour and sub-day units add absolute time and do not adjust. +DATEADD_TIMEZONE_DST_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "dst_spring_forward_day", + doc={"date": datetime(2021, 3, 13, 15, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateAdd": { + "startDate": "$date", + "unit": "day", + "amount": 1, + "timezone": "America/New_York", + } + }, + expected=datetime(2021, 3, 14, 14, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should DST-adjust a day across spring-forward", + ), + ExpressionTestCase( + "dst_spring_forward_hour_24", + doc={"date": datetime(2021, 3, 13, 15, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateAdd": { + "startDate": "$date", + "unit": "hour", + "amount": 24, + "timezone": "America/New_York", + } + }, + expected=datetime(2021, 3, 14, 15, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should not DST-adjust 24 hours across spring-forward", + ), + ExpressionTestCase( + "dst_spring_forward_week", + doc={"date": datetime(2021, 3, 7, 10, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateAdd": { + "startDate": "$date", + "unit": "week", + "amount": 1, + "timezone": "America/New_York", + } + }, + expected=datetime(2021, 3, 14, 9, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should DST-adjust a week across spring-forward", + ), + ExpressionTestCase( + "dst_spring_forward_month", + doc={"date": datetime(2021, 2, 14, 10, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateAdd": { + "startDate": "$date", + "unit": "month", + "amount": 1, + "timezone": "America/New_York", + } + }, + expected=datetime(2021, 3, 14, 9, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should DST-adjust a month across spring-forward", + ), + ExpressionTestCase( + "dst_spring_forward_quarter", + doc={"date": datetime(2021, 1, 14, 10, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateAdd": { + "startDate": "$date", + "unit": "quarter", + "amount": 1, + "timezone": "America/New_York", + } + }, + expected=datetime(2021, 4, 14, 9, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should DST-adjust a quarter across spring-forward", + ), + ExpressionTestCase( + "dst_fall_back_day", + doc={"date": datetime(2021, 11, 6, 10, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateAdd": { + "startDate": "$date", + "unit": "day", + "amount": 1, + "timezone": "America/New_York", + } + }, + expected=datetime(2021, 11, 7, 11, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should DST-adjust a day across fall-back", + ), + ExpressionTestCase( + "dst_fall_back_hour_24", + doc={"date": datetime(2021, 11, 6, 10, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateAdd": { + "startDate": "$date", + "unit": "hour", + "amount": 24, + "timezone": "America/New_York", + } + }, + expected=datetime(2021, 11, 7, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should not DST-adjust 24 hours across fall-back", + ), + ExpressionTestCase( + "dst_fall_back_week", + doc={"date": datetime(2021, 10, 31, 10, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateAdd": { + "startDate": "$date", + "unit": "week", + "amount": 1, + "timezone": "America/New_York", + } + }, + expected=datetime(2021, 11, 7, 11, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should DST-adjust a week across fall-back", + ), + ExpressionTestCase( + "dst_spring_minute_no_adjust", + doc={"date": datetime(2021, 3, 14, 6, 59, 0, tzinfo=timezone.utc)}, + expression={ + "$dateAdd": { + "startDate": "$date", + "unit": "minute", + "amount": 1, + "timezone": "America/New_York", + } + }, + expected=datetime(2021, 3, 14, 7, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should not DST-adjust a minute", + ), + ExpressionTestCase( + "dst_spring_second_no_adjust", + doc={"date": datetime(2021, 3, 14, 6, 59, 59, tzinfo=timezone.utc)}, + expression={ + "$dateAdd": { + "startDate": "$date", + "unit": "second", + "amount": 1, + "timezone": "America/New_York", + } + }, + expected=datetime(2021, 3, 14, 7, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should not DST-adjust a second", + ), + ExpressionTestCase( + "dst_spring_millisecond_no_adjust", + doc={"date": datetime(2021, 3, 14, 6, 59, 59, 999000, tzinfo=timezone.utc)}, + expression={ + "$dateAdd": { + "startDate": "$date", + "unit": "millisecond", + "amount": 1, + "timezone": "America/New_York", + } + }, + expected=datetime(2021, 3, 14, 7, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should not DST-adjust a millisecond", + ), + ExpressionTestCase( + "dst_europe_paris_hour_no_adjust", + doc={"date": datetime(2020, 10, 24, 18, 10, 0, tzinfo=timezone.utc)}, + expression={ + "$dateAdd": { + "startDate": "$date", + "unit": "hour", + "amount": 24, + "timezone": "Europe/Paris", + } + }, + expected=datetime(2020, 10, 25, 18, 10, 0, tzinfo=timezone.utc), + msg="$dateAdd should not DST-adjust 24 hours in Europe/Paris", + ), + ExpressionTestCase( + "dst_europe_paris_day_adjust", + doc={"date": datetime(2020, 10, 24, 18, 10, 0, tzinfo=timezone.utc)}, + expression={ + "$dateAdd": { + "startDate": "$date", + "unit": "day", + "amount": 1, + "timezone": "Europe/Paris", + } + }, + expected=datetime(2020, 10, 25, 19, 10, 0, tzinfo=timezone.utc), + msg="$dateAdd should DST-adjust a day across Europe/Paris fall-back", + ), +] + +# Property [Timezone Field Reference]: the timezone operand resolves from a field, and a +# missing timezone field reference returns null. +DATEADD_TIMEZONE_FIELD_REF_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "timezone_field_ref", + doc={"tz": "Europe/Paris"}, + expression={ + "$dateAdd": { + "startDate": datetime(2020, 12, 31, 12, 10, 5, tzinfo=timezone.utc), + "unit": "month", + "amount": 2, + "timezone": "$tz", + } + }, + expected=datetime(2021, 2, 28, 12, 10, 5, tzinfo=timezone.utc), + msg="$dateAdd should resolve the timezone from a field reference", + ), + ExpressionTestCase( + "timezone_missing_field_ref", + doc={}, + expression={ + "$dateAdd": { + "startDate": datetime(2020, 12, 31, 12, 10, 5, tzinfo=timezone.utc), + "unit": "month", + "amount": 2, + "timezone": "$tz", + } + }, + expected=None, + msg="$dateAdd should return null for a missing timezone field reference", + ), +] + +# Property [Null Timezone]: a null timezone returns null. +DATEADD_TIMEZONE_NULL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "timezone_null", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateAdd": {"startDate": "$date", "unit": "hour", "amount": 5, "timezone": None} + }, + expected=None, + msg="$dateAdd should return null when the timezone is null", + ), +] + +DATEADD_TIMEZONE_TESTS: list[ExpressionTestCase] = ( + DATEADD_TIMEZONE_OLSON_TESTS + + DATEADD_TIMEZONE_OFFSET_TESTS + + DATEADD_TIMEZONE_DST_TESTS + + DATEADD_TIMEZONE_FIELD_REF_TESTS + + DATEADD_TIMEZONE_NULL_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATEADD_TIMEZONE_TESTS)) +def test_dateAdd_timezone(collection, test_case: ExpressionTestCase): + """Test $dateAdd applies the timezone operand for calendar-aware units.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_basic.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_basic.py new file mode 100644 index 000000000..5cae1776f --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_basic.py @@ -0,0 +1,185 @@ +"""$dateDiff basic behavior: valid differences, result sign, and long return type.""" + +from datetime import datetime, timezone + +import pytest +from bson import Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + INT64_ZERO, +) + +# Property [Valid Operation]: with valid start date, end date, and unit, the difference is +# returned as a long, and the optional timezone and startOfWeek fields are accepted. +DATEDIFF_VALID_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "all_required", + expression={ + "$dateDiff": { + "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2024, 6, 1, tzinfo=timezone.utc), + "unit": "day", + } + }, + expected=Int64(152), + msg="$dateDiff should return the day difference for the required fields", + ), + ExpressionTestCase( + "with_timezone", + expression={ + "$dateDiff": { + "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2024, 6, 1, tzinfo=timezone.utc), + "unit": "day", + "timezone": "UTC", + } + }, + expected=Int64(152), + msg="$dateDiff should accept an optional timezone", + ), + ExpressionTestCase( + "with_startOfWeek", + expression={ + "$dateDiff": { + "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2024, 1, 31, tzinfo=timezone.utc), + "unit": "week", + "startOfWeek": "monday", + } + }, + expected=Int64(4), + msg="$dateDiff should accept an optional startOfWeek for the week unit", + ), + ExpressionTestCase( + "all_five_fields", + expression={ + "$dateDiff": { + "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2024, 1, 31, tzinfo=timezone.utc), + "unit": "week", + "timezone": "UTC", + "startOfWeek": "monday", + } + }, + expected=Int64(4), + msg="$dateDiff should accept all five fields together", + ), +] + +# Property [Sign Handling]: the difference is positive when endDate follows startDate, +# negative when it precedes, and zero when they are equal. +DATEDIFF_SIGN_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "positive_diff", + expression={ + "$dateDiff": { + "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2024, 6, 1, tzinfo=timezone.utc), + "unit": "day", + } + }, + expected=Int64(152), + msg="$dateDiff should return a positive difference when endDate follows startDate", + ), + ExpressionTestCase( + "negative_diff", + expression={ + "$dateDiff": { + "startDate": datetime(2024, 6, 1, tzinfo=timezone.utc), + "endDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "unit": "day", + } + }, + expected=Int64(-152), + msg="$dateDiff should return a negative difference when endDate precedes startDate", + ), + ExpressionTestCase( + "zero_diff", + expression={ + "$dateDiff": { + "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "unit": "day", + } + }, + expected=INT64_ZERO, + msg="$dateDiff should return zero when startDate equals endDate", + ), + ExpressionTestCase( + "negative_year", + expression={ + "$dateDiff": { + "startDate": datetime(2022, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2021, 1, 1, tzinfo=timezone.utc), + "unit": "year", + } + }, + expected=Int64(-1), + msg="$dateDiff should return a negative year difference", + ), + ExpressionTestCase( + "negative_month", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 6, 1, tzinfo=timezone.utc), + "endDate": datetime(2021, 1, 1, tzinfo=timezone.utc), + "unit": "month", + } + }, + expected=Int64(-5), + msg="$dateDiff should return a negative month difference", + ), +] + +# Property [Return Type]: $dateDiff returns a long regardless of the unit. +DATEDIFF_RETURN_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "return_type_day", + expression={ + "$type": { + "$dateDiff": { + "startDate": datetime(2021, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2021, 1, 2, tzinfo=timezone.utc), + "unit": "day", + } + } + }, + expected="long", + msg="$dateDiff should return a long for a day difference", + ), + ExpressionTestCase( + "return_type_millisecond", + expression={ + "$type": { + "$dateDiff": { + "startDate": datetime(2021, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2021, 1, 2, tzinfo=timezone.utc), + "unit": "millisecond", + } + } + }, + expected="long", + msg="$dateDiff should return a long for a millisecond difference", + ), +] + +DATEDIFF_BASIC_TESTS: list[ExpressionTestCase] = ( + DATEDIFF_VALID_TESTS + DATEDIFF_SIGN_TESTS + DATEDIFF_RETURN_TYPE_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATEDIFF_BASIC_TESTS)) +def test_dateDiff_basic(collection, test_case: ExpressionTestCase): + """Test $dateDiff produces the correct value, sign, and long return type.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_counting.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_counting.py new file mode 100644 index 000000000..a14765805 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_counting.py @@ -0,0 +1,390 @@ +"""$dateDiff boundary counting: unit boundaries crossed, quarters, and leap-year day counts.""" + +from datetime import datetime, timezone + +import pytest +from bson import Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import DATE_YEAR_1, INT64_ZERO + +# Property [Boundary Counting]: the difference counts unit boundaries crossed, not elapsed +# whole units, so sub-unit remainders do not change the count. +DATEDIFF_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "year_exact", + expression={ + "$dateDiff": { + "startDate": datetime(2010, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2011, 1, 1, tzinfo=timezone.utc), + "unit": "year", + } + }, + expected=Int64(1), + msg="$dateDiff should count one year boundary for an exact year", + ), + ExpressionTestCase( + "year_18m_still_1", + expression={ + "$dateDiff": { + "startDate": datetime(2010, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2011, 6, 30, tzinfo=timezone.utc), + "unit": "year", + } + }, + expected=Int64(1), + msg="$dateDiff should count one year boundary for eighteen months", + ), + ExpressionTestCase( + "month_12", + expression={ + "$dateDiff": { + "startDate": datetime(2010, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2011, 1, 1, tzinfo=timezone.utc), + "unit": "month", + } + }, + expected=Int64(12), + msg="$dateDiff should count twelve month boundaries for one year", + ), + ExpressionTestCase( + "month_60d_is_1", + expression={ + "$dateDiff": { + "startDate": datetime(2010, 3, 1, tzinfo=timezone.utc), + "endDate": datetime(2010, 4, 30, tzinfo=timezone.utc), + "unit": "month", + } + }, + expected=Int64(1), + msg="$dateDiff should count one month boundary within sixty days", + ), + ExpressionTestCase( + "day_365", + expression={ + "$dateDiff": { + "startDate": datetime(2010, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2011, 1, 1, tzinfo=timezone.utc), + "unit": "day", + } + }, + expected=Int64(365), + msg="$dateDiff should count 365 days across a non-leap year", + ), + ExpressionTestCase( + "day_60", + expression={ + "$dateDiff": { + "startDate": datetime(2010, 3, 1, tzinfo=timezone.utc), + "endDate": datetime(2010, 4, 30, tzinfo=timezone.utc), + "unit": "day", + } + }, + expected=Int64(60), + msg="$dateDiff should count sixty days from March to April", + ), + ExpressionTestCase( + "day_546", + expression={ + "$dateDiff": { + "startDate": datetime(2010, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2011, 7, 1, tzinfo=timezone.utc), + "unit": "day", + } + }, + expected=Int64(546), + msg="$dateDiff should count 546 days across eighteen months", + ), + ExpressionTestCase( + "day_no_cross", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + "endDate": datetime(2021, 1, 1, 23, 59, 59, tzinfo=timezone.utc), + "unit": "day", + } + }, + expected=INT64_ZERO, + msg="$dateDiff should count zero days within a single calendar day", + ), + ExpressionTestCase( + "day_cross", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 1, 1, 23, 59, 59, tzinfo=timezone.utc), + "endDate": datetime(2021, 1, 2, 0, 0, 1, tzinfo=timezone.utc), + "unit": "day", + } + }, + expected=Int64(1), + msg="$dateDiff should count one day when crossing midnight by seconds", + ), + ExpressionTestCase( + "hour_no_cross", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + "endDate": datetime(2021, 1, 1, 0, 59, 59, tzinfo=timezone.utc), + "unit": "hour", + } + }, + expected=INT64_ZERO, + msg="$dateDiff should count zero hours within a single hour", + ), + ExpressionTestCase( + "hour_cross", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 1, 1, 0, 59, 59, tzinfo=timezone.utc), + "endDate": datetime(2021, 1, 1, 1, 0, 1, tzinfo=timezone.utc), + "unit": "hour", + } + }, + expected=Int64(1), + msg="$dateDiff should count one hour when crossing the hour boundary by seconds", + ), + ExpressionTestCase( + "minute_no_cross", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + "endDate": datetime(2021, 1, 1, 0, 0, 59, tzinfo=timezone.utc), + "unit": "minute", + } + }, + expected=INT64_ZERO, + msg="$dateDiff should count zero minutes within a single minute", + ), + ExpressionTestCase( + "minute_cross", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 1, 1, 0, 0, 59, tzinfo=timezone.utc), + "endDate": datetime(2021, 1, 1, 0, 1, 1, tzinfo=timezone.utc), + "unit": "minute", + } + }, + expected=Int64(1), + msg="$dateDiff should count one minute when crossing the minute boundary by seconds", + ), + ExpressionTestCase( + "second_no_cross", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 1, 1, 0, 0, 0, 0, tzinfo=timezone.utc), + "endDate": datetime(2021, 1, 1, 0, 0, 0, 999000, tzinfo=timezone.utc), + "unit": "second", + } + }, + expected=INT64_ZERO, + msg="$dateDiff should count zero seconds within a single second", + ), + ExpressionTestCase( + "second_cross", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 1, 1, 0, 0, 0, 999000, tzinfo=timezone.utc), + "endDate": datetime(2021, 1, 1, 0, 0, 1, 1000, tzinfo=timezone.utc), + "unit": "second", + } + }, + expected=Int64(1), + msg="$dateDiff should count one second when crossing the second boundary by milliseconds", + ), + ExpressionTestCase( + "millisecond_1", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 1, 1, 0, 0, 0, 0, tzinfo=timezone.utc), + "endDate": datetime(2021, 1, 1, 0, 0, 0, 1000, tzinfo=timezone.utc), + "unit": "millisecond", + } + }, + expected=Int64(1), + msg="$dateDiff should count one millisecond", + ), + ExpressionTestCase( + "large_year_range", + expression={ + "$dateDiff": { + "startDate": DATE_YEAR_1, + "endDate": datetime(9999, 12, 31, tzinfo=timezone.utc), + "unit": "year", + } + }, + expected=Int64(9998), + msg="$dateDiff should count years across the full representable range", + ), + ExpressionTestCase( + "large_day_range", + expression={ + "$dateDiff": { + "startDate": DATE_YEAR_1, + "endDate": datetime(9999, 12, 31, tzinfo=timezone.utc), + "unit": "day", + } + }, + expected=Int64(3_652_058), + msg="$dateDiff should count days across the full representable range", + ), +] + +# Property [Quarter Counting]: the difference counts quarter boundaries crossed. +DATEDIFF_QUARTER_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "quarter_still_q1", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2021, 3, 31, tzinfo=timezone.utc), + "unit": "quarter", + } + }, + expected=INT64_ZERO, + msg="$dateDiff should count zero quarters within the first quarter", + ), + ExpressionTestCase( + "quarter_cross_q2", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2021, 4, 1, tzinfo=timezone.utc), + "unit": "quarter", + } + }, + expected=Int64(1), + msg="$dateDiff should count one quarter when crossing into the second quarter", + ), + ExpressionTestCase( + "quarter_3", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2021, 12, 31, tzinfo=timezone.utc), + "unit": "quarter", + } + }, + expected=Int64(3), + msg="$dateDiff should count three quarters within a year", + ), + ExpressionTestCase( + "quarter_4", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2022, 1, 1, tzinfo=timezone.utc), + "unit": "quarter", + } + }, + expected=Int64(4), + msg="$dateDiff should count four quarters across a year", + ), + ExpressionTestCase( + "quarter_negative", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 4, 1, tzinfo=timezone.utc), + "endDate": datetime(2021, 1, 1, tzinfo=timezone.utc), + "unit": "quarter", + } + }, + expected=Int64(-1), + msg="$dateDiff should return a negative quarter difference", + ), +] + +# Property [Leap Year]: day counting honors leap years, including the century leap rule. +DATEDIFF_LEAP_YEAR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "leap_feb28_mar1", + expression={ + "$dateDiff": { + "startDate": datetime(2020, 2, 28, tzinfo=timezone.utc), + "endDate": datetime(2020, 3, 1, tzinfo=timezone.utc), + "unit": "day", + } + }, + expected=Int64(2), + msg="$dateDiff should count two days across Feb 29 in a leap year", + ), + ExpressionTestCase( + "non_leap_feb28_mar1", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 2, 28, tzinfo=timezone.utc), + "endDate": datetime(2021, 3, 1, tzinfo=timezone.utc), + "unit": "day", + } + }, + expected=Int64(1), + msg="$dateDiff should count one day from Feb 28 in a non-leap year", + ), + ExpressionTestCase( + "full_leap_year", + expression={ + "$dateDiff": { + "startDate": datetime(2020, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2021, 1, 1, tzinfo=timezone.utc), + "unit": "day", + } + }, + expected=Int64(366), + msg="$dateDiff should count 366 days across a full leap year", + ), + ExpressionTestCase( + "full_non_leap_year", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2022, 1, 1, tzinfo=timezone.utc), + "unit": "day", + } + }, + expected=Int64(365), + msg="$dateDiff should count 365 days across a full non-leap year", + ), + ExpressionTestCase( + "century_leap_2000", + expression={ + "$dateDiff": { + "startDate": datetime(2000, 2, 28, tzinfo=timezone.utc), + "endDate": datetime(2000, 3, 1, tzinfo=timezone.utc), + "unit": "day", + } + }, + expected=Int64(2), + msg="$dateDiff should treat 2000 as a leap century year", + ), + ExpressionTestCase( + "century_non_leap_1900", + expression={ + "$dateDiff": { + "startDate": datetime(1900, 2, 28, tzinfo=timezone.utc), + "endDate": datetime(1900, 3, 1, tzinfo=timezone.utc), + "unit": "day", + } + }, + expected=Int64(1), + msg="$dateDiff should treat 1900 as a non-leap century year", + ), +] + +DATEDIFF_COUNTING_TESTS: list[ExpressionTestCase] = ( + DATEDIFF_BOUNDARY_TESTS + DATEDIFF_QUARTER_TESTS + DATEDIFF_LEAP_YEAR_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATEDIFF_COUNTING_TESTS)) +def test_dateDiff_counting(collection, test_case: ExpressionTestCase): + """Test $dateDiff counts unit boundaries crossed, including quarters and leap years.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_errors.py new file mode 100644 index 000000000..bb2d414f4 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_errors.py @@ -0,0 +1,438 @@ +"""$dateDiff rejection cases: invalid operand types, unit, timezone, startOfWeek, and shape.""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + DATEDIFF_MISSING_ENDDATE_ERROR, + DATEDIFF_MISSING_STARTDATE_ERROR, + DATEDIFF_MISSING_UNIT_ERROR, + DATEDIFF_NON_OBJECT_ERROR, + DATEDIFF_UNKNOWN_FIELD_ERROR, + FAILED_TO_PARSE_ERROR, + INVALID_DATE_UNIT_ERROR, + INVALID_DATE_VALUE_ERROR, + INVALID_STARTOFWEEK_ERROR, + INVALID_STARTOFWEEK_TYPE_ERROR, + INVALID_TIMEZONE_ERROR, + INVALID_TIMEZONE_TYPE_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NEGATIVE_INFINITY, +) + +# Property [StartDate Type]: a non-date, non-Timestamp, non-ObjectId startDate is rejected. +DATEDIFF_STARTDATE_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"startDate_{tid}", + expression={ + "$dateDiff": { + "startDate": val, + "endDate": datetime(2024, 6, 1, tzinfo=timezone.utc), + "unit": "day", + } + }, + error_code=INVALID_DATE_VALUE_ERROR, + msg=f"$dateDiff should reject a {tid} startDate", + ) + for tid, val in [ + ("string", "2024-01-01"), + ("int", 123), + ("int64", Int64(123)), + ("double", 1.0), + ("decimal128", Decimal128("1")), + ("boolean", True), + ("array", [2024, 1, 1]), + ("empty_array", []), + ("single_date_array", [datetime(2024, 1, 1, tzinfo=timezone.utc)]), + ("object", {"year": 2024}), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*")), + ("javascript", Code("function() {}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("decimal128_infinity", DECIMAL128_INFINITY), + ("decimal128_neg_infinity", DECIMAL128_NEGATIVE_INFINITY), + ] +] + +# Property [EndDate Type]: a non-date, non-Timestamp, non-ObjectId endDate is rejected. +DATEDIFF_ENDDATE_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"endDate_{tid}", + expression={ + "$dateDiff": { + "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "endDate": val, + "unit": "day", + } + }, + error_code=INVALID_DATE_VALUE_ERROR, + msg=f"$dateDiff should reject a {tid} endDate", + ) + for tid, val in [ + ("string", "2024-06-01"), + ("int", 123), + ("int64", Int64(123)), + ("double", 1.0), + ("decimal128", Decimal128("1")), + ("boolean", True), + ("array", [2024, 6, 1]), + ("empty_array", []), + ("single_date_array", [datetime(2024, 6, 1, tzinfo=timezone.utc)]), + ("object", {"year": 2024}), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*")), + ("javascript", Code("function() {}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("decimal128_infinity", DECIMAL128_INFINITY), + ("decimal128_neg_infinity", DECIMAL128_NEGATIVE_INFINITY), + ] +] + +# Property [Unit Type]: a non-string unit is rejected as an invalid date unit. +DATEDIFF_UNIT_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"unit_{tid}", + expression={ + "$dateDiff": { + "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2024, 6, 1, tzinfo=timezone.utc), + "unit": val, + } + }, + error_code=INVALID_DATE_UNIT_ERROR, + msg=f"$dateDiff should reject a {tid} unit", + ) + for tid, val in [ + ("number", 1), + ("int64", Int64(1)), + ("double", 1.0), + ("decimal128", Decimal128("1")), + ("boolean", True), + ("array", ["day"]), + ("object", {"t": "day"}), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("objectid", ObjectId("600000000000000000000000")), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex("day")), + ("javascript", Code("function() {}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Unit String]: an unrecognized unit string, including wrong case and plurals, is +# rejected at parse time. +DATEDIFF_UNIT_STRING_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"unit_{tid}", + expression={ + "$dateDiff": { + "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2024, 6, 1, tzinfo=timezone.utc), + "unit": val, + } + }, + error_code=FAILED_TO_PARSE_ERROR, + msg=f"$dateDiff should reject the {desc}", + ) + for tid, val, desc in [ + ("invalid_string", "invalid", "unrecognized unit string"), + ("empty_string", "", "empty string unit"), + ("epoch_invalid", "epoch", "invalid unit epoch"), + ("mixed_case_Day", "Day", "mixed-case unit Day"), + ("mixed_case_Hour", "Hour", "mixed-case unit Hour"), + ("mixed_case_Month", "Month", "mixed-case unit Month"), + ("mixed_case_Quarter", "Quarter", "mixed-case unit Quarter"), + ("mixed_case_Week", "Week", "mixed-case unit Week"), + ("mixed_case_Second", "Second", "mixed-case unit Second"), + ("mixed_case_Minute", "Minute", "mixed-case unit Minute"), + ("mixed_case_Millisecond", "Millisecond", "mixed-case unit Millisecond"), + ("mixed_case_Year", "Year", "mixed-case unit Year"), + ("uppercase_DAY", "DAY", "uppercase unit DAY"), + ("uppercase_YEAR", "YEAR", "uppercase unit YEAR"), + ("uppercase_HOUR", "HOUR", "uppercase unit HOUR"), + ("uppercase_MONTH", "MONTH", "uppercase unit MONTH"), + ("uppercase_MILLISECOND", "MILLISECOND", "uppercase unit MILLISECOND"), + ("uppercase_QUARTER", "QUARTER", "uppercase unit QUARTER"), + ("uppercase_WEEK", "WEEK", "uppercase unit WEEK"), + ("uppercase_SECOND", "SECOND", "uppercase unit SECOND"), + ("uppercase_MINUTE", "MINUTE", "uppercase unit MINUTE"), + ("plural_years", "years", "plural unit years"), + ("plural_months", "months", "plural unit months"), + ("plural_days", "days", "plural unit days"), + ("plural_hours", "hours", "plural unit hours"), + ("plural_minutes", "minutes", "plural unit minutes"), + ("plural_seconds", "seconds", "plural unit seconds"), + ("plural_milliseconds", "milliseconds", "plural unit milliseconds"), + ("plural_weeks", "weeks", "plural unit weeks"), + ("plural_quarters", "quarters", "plural unit quarters"), + ] +] + +# Property [Array-Resolving Path]: a field path that resolves to an array is delivered to the +# operator, which rejects it under its date type contract. +DATEDIFF_ARRAY_PATH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "composite_array_path", + doc={ + "a": [ + {"b": datetime(2021, 1, 1, tzinfo=timezone.utc)}, + {"b": datetime(2021, 6, 1, tzinfo=timezone.utc)}, + ] + }, + expression={ + "$dateDiff": { + "startDate": "$a.b", + "endDate": datetime(2021, 1, 2, tzinfo=timezone.utc), + "unit": "day", + } + }, + error_code=INVALID_DATE_VALUE_ERROR, + msg="$dateDiff should reject a composite array field path as startDate", + ), + ExpressionTestCase( + "single_element_array_path", + doc={"a": [{"b": datetime(2021, 6, 15, tzinfo=timezone.utc)}]}, + expression={ + "$dateDiff": { + "startDate": "$a.b", + "endDate": datetime(2021, 6, 16, tzinfo=timezone.utc), + "unit": "day", + } + }, + error_code=INVALID_DATE_VALUE_ERROR, + msg="$dateDiff should reject a single-element array field path as startDate", + ), + ExpressionTestCase( + "array_index_path", + doc={"a": [{"b": datetime(2021, 1, 1, tzinfo=timezone.utc)}]}, + expression={ + "$dateDiff": { + "startDate": "$a.0.b", + "endDate": datetime(2021, 1, 2, tzinfo=timezone.utc), + "unit": "day", + } + }, + error_code=INVALID_DATE_VALUE_ERROR, + msg="$dateDiff should reject an array-index field path resolving to an array as startDate", + ), +] + +# Property [Invalid Timezone]: an unrecognized or malformed timezone string is rejected. +DATEDIFF_TIMEZONE_INVALID_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"tz_{tid}", + expression={ + "$dateDiff": { + "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2024, 6, 1, tzinfo=timezone.utc), + "unit": "day", + "timezone": tz, + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg=f"$dateDiff should reject {desc}", + ) + for tid, tz, desc in [ + ("offset_3digit_hours", "+100:00", "a 3-digit hour offset"), + ("invalid_string", "NotATimezone", "an unrecognized Olson timezone"), + ("empty_string", "", "an empty string timezone"), + ("olson_wrong_case_lowercase", "america/new_york", "an all-lowercase Olson name"), + ("olson_wrong_case_uppercase", "AMERICA/NEW_YORK", "an all-uppercase Olson name"), + ] +] + +# Property [Timezone Type]: a non-string timezone is rejected as an invalid type. +DATEDIFF_TIMEZONE_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"tz_{tid}", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2021, 1, 2, tzinfo=timezone.utc), + "unit": "day", + "timezone": val, + } + }, + error_code=INVALID_TIMEZONE_TYPE_ERROR, + msg=f"$dateDiff should reject a {tid} timezone", + ) + for tid, val in [ + ("number", 5), + ("int64", Int64(5)), + ("double", 5.0), + ("decimal128", Decimal128("5")), + ("boolean", True), + ("array", ["UTC"]), + ("object", {"tz": "UTC"}), + ("datetime", datetime(2021, 1, 1, tzinfo=timezone.utc)), + ("objectid", ObjectId("600000000000000000000000")), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex("UTC")), + ("javascript", Code("function() {}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Invalid StartOfWeek]: an unrecognized startOfWeek string is rejected. +DATEDIFF_STARTOFWEEK_INVALID_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"sow_{tid}", + expression={ + "$dateDiff": { + "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2024, 1, 31, tzinfo=timezone.utc), + "unit": "week", + "startOfWeek": sow, + } + }, + error_code=INVALID_STARTOFWEEK_ERROR, + msg=f"$dateDiff should reject {desc}", + ) + for tid, sow, desc in [ + ("invalid_string", "notaday", "an unrecognized startOfWeek string"), + ("empty_string", "", "an empty startOfWeek string"), + ] +] + +# Property [StartOfWeek Type]: a non-string startOfWeek is rejected as an invalid type. +DATEDIFF_STARTOFWEEK_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"sow_{tid}", + expression={ + "$dateDiff": { + "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2024, 1, 31, tzinfo=timezone.utc), + "unit": "week", + "startOfWeek": val, + } + }, + error_code=INVALID_STARTOFWEEK_TYPE_ERROR, + msg=f"$dateDiff should reject a {tid} startOfWeek", + ) + for tid, val in [ + ("number", 1), + ("int64", Int64(1)), + ("double", 1.0), + ("decimal128", Decimal128("1")), + ("boolean", True), + ("array", ["monday"]), + ("object", {"day": "monday"}), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("objectid", ObjectId("600000000000000000000000")), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex("monday")), + ("javascript", Code("function() {}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Argument Shape]: a missing required field, an unknown field, or a non-object +# argument is rejected. +DATEDIFF_ARGUMENT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "arg_missing_startDate", + expression={ + "$dateDiff": {"endDate": datetime(2024, 6, 1, tzinfo=timezone.utc), "unit": "day"} + }, + error_code=DATEDIFF_MISSING_STARTDATE_ERROR, + msg="$dateDiff should error when startDate is missing", + ), + ExpressionTestCase( + "arg_missing_endDate", + expression={ + "$dateDiff": {"startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), "unit": "day"} + }, + error_code=DATEDIFF_MISSING_ENDDATE_ERROR, + msg="$dateDiff should error when endDate is missing", + ), + ExpressionTestCase( + "arg_missing_unit", + expression={ + "$dateDiff": { + "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2024, 6, 1, tzinfo=timezone.utc), + } + }, + error_code=DATEDIFF_MISSING_UNIT_ERROR, + msg="$dateDiff should error when unit is missing", + ), + ExpressionTestCase( + "arg_empty_object", + expression={"$dateDiff": {}}, + error_code=DATEDIFF_MISSING_STARTDATE_ERROR, + msg="$dateDiff should report a missing startDate for an empty argument object", + ), + ExpressionTestCase( + "arg_unknown_field", + expression={ + "$dateDiff": { + "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2024, 6, 1, tzinfo=timezone.utc), + "unit": "day", + "foo": 1, + } + }, + error_code=DATEDIFF_UNKNOWN_FIELD_ERROR, + msg="$dateDiff should error for an unknown field", + ), + ExpressionTestCase( + "arg_non_object_string", + expression={"$dateDiff": "string"}, + error_code=DATEDIFF_NON_OBJECT_ERROR, + msg="$dateDiff should reject a string argument as a non-object", + ), + ExpressionTestCase( + "arg_non_object_array", + expression={"$dateDiff": [1, 2]}, + error_code=DATEDIFF_NON_OBJECT_ERROR, + msg="$dateDiff should reject an array argument as a non-object", + ), + ExpressionTestCase( + "arg_non_object_number", + expression={"$dateDiff": 123}, + error_code=DATEDIFF_NON_OBJECT_ERROR, + msg="$dateDiff should reject a number argument as a non-object", + ), +] + +DATEDIFF_ERROR_TESTS: list[ExpressionTestCase] = ( + DATEDIFF_STARTDATE_TYPE_ERROR_TESTS + + DATEDIFF_ENDDATE_TYPE_ERROR_TESTS + + DATEDIFF_UNIT_TYPE_ERROR_TESTS + + DATEDIFF_UNIT_STRING_ERROR_TESTS + + DATEDIFF_ARRAY_PATH_TESTS + + DATEDIFF_TIMEZONE_INVALID_TESTS + + DATEDIFF_TIMEZONE_TYPE_ERROR_TESTS + + DATEDIFF_STARTOFWEEK_INVALID_TESTS + + DATEDIFF_STARTOFWEEK_TYPE_ERROR_TESTS + + DATEDIFF_ARGUMENT_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATEDIFF_ERROR_TESTS)) +def test_dateDiff_errors(collection, test_case: ExpressionTestCase): + """Test $dateDiff rejects invalid operand types, timezones, startOfWeek, and shapes.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_expressions.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_expressions.py new file mode 100644 index 000000000..f667b17d8 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_expressions.py @@ -0,0 +1,91 @@ +"""$dateDiff operand evaluation: startDate, endDate, unit, and timezone from field references.""" + +from datetime import datetime, timezone + +import pytest +from bson import Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import INT64_ZERO + +# Property [Field Reference Operands]: the startDate, endDate, unit, and timezone operands +# resolve from field references, including a nested path, and a missing timezone reference +# returns null. +DATEDIFF_FIELD_REF_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "field_refs", + doc={ + "s": datetime(2024, 1, 1, tzinfo=timezone.utc), + "e": datetime(2024, 1, 6, tzinfo=timezone.utc), + }, + expression={"$dateDiff": {"startDate": "$s", "endDate": "$e", "unit": "day"}}, + expected=Int64(5), + msg="$dateDiff should resolve startDate and endDate from field references", + ), + ExpressionTestCase( + "field_ref_unit", + doc={"u": "day"}, + expression={ + "$dateDiff": { + "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2024, 1, 6, tzinfo=timezone.utc), + "unit": "$u", + } + }, + expected=Int64(5), + msg="$dateDiff should resolve the unit from a field reference", + ), + ExpressionTestCase( + "nested_field_path", + doc={ + "a": { + "s": datetime(2021, 1, 1, tzinfo=timezone.utc), + "e": datetime(2021, 1, 2, tzinfo=timezone.utc), + } + }, + expression={"$dateDiff": {"startDate": "$a.s", "endDate": "$a.e", "unit": "day"}}, + expected=Int64(1), + msg="$dateDiff should resolve date operands from a nested field path", + ), + ExpressionTestCase( + "timezone_field_ref", + doc={ + "s": datetime(2021, 12, 31, 23, 0, 0, tzinfo=timezone.utc), + "e": datetime(2022, 1, 1, 1, 0, 0, tzinfo=timezone.utc), + "tz": "+02:00", + }, + expression={ + "$dateDiff": {"startDate": "$s", "endDate": "$e", "unit": "year", "timezone": "$tz"} + }, + expected=INT64_ZERO, + msg="$dateDiff should resolve the timezone from a field reference", + ), + ExpressionTestCase( + "missing_timezone_field_ref", + doc={ + "s": datetime(2021, 1, 1, tzinfo=timezone.utc), + "e": datetime(2021, 1, 2, tzinfo=timezone.utc), + }, + expression={ + "$dateDiff": {"startDate": "$s", "endDate": "$e", "unit": "day", "timezone": "$tz"} + }, + expected=None, + msg="$dateDiff should return null for a missing timezone field reference", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(DATEDIFF_FIELD_REF_TESTS)) +def test_dateDiff_expressions(collection, test_case: ExpressionTestCase): + """Test $dateDiff resolves its operands from field references.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_input_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_input_types.py new file mode 100644 index 000000000..4ae0d0a59 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_input_types.py @@ -0,0 +1,234 @@ +"""$dateDiff date-like operand types: ObjectId, Timestamp, and mixed sources.""" + +from datetime import datetime, timezone + +import pytest +from bson import Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.date_utils import ( + oid_from_args, + ts_from_args, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + INT64_ZERO, +) + +# Property [ObjectId Source]: an ObjectId date operand uses its embedded timestamp for either +# position and for the timezone option. +DATEDIFF_OBJECTID_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "oid_day", + doc={"s": oid_from_args(2024, 1, 1, 0, 0, 0), "e": oid_from_args(2024, 1, 6, 0, 0, 0)}, + expression={"$dateDiff": {"startDate": "$s", "endDate": "$e", "unit": "day"}}, + expected=Int64(5), + msg="$dateDiff should count days between two ObjectId dates", + ), + ExpressionTestCase( + "oid_hour", + doc={"s": oid_from_args(2024, 1, 1, 0, 0, 0)}, + expression={ + "$dateDiff": { + "startDate": "$s", + "endDate": datetime(2024, 1, 1, 5, 0, 0, tzinfo=timezone.utc), + "unit": "hour", + } + }, + expected=Int64(5), + msg="$dateDiff should count hours from an ObjectId startDate", + ), + ExpressionTestCase( + "oid_month", + doc={"s": oid_from_args(2024, 1, 1, 0, 0, 0), "e": oid_from_args(2024, 6, 1, 0, 0, 0)}, + expression={"$dateDiff": {"startDate": "$s", "endDate": "$e", "unit": "month"}}, + expected=Int64(5), + msg="$dateDiff should count months between two ObjectId dates", + ), + ExpressionTestCase( + "oid_year", + doc={ + "s": oid_from_args(2024, 1, 1, 0, 0, 0), + "e": oid_from_args(2024, 12, 31, 23, 59, 59), + }, + expression={"$dateDiff": {"startDate": "$s", "endDate": "$e", "unit": "year"}}, + expected=INT64_ZERO, + msg="$dateDiff should count zero years within the same ObjectId year", + ), + ExpressionTestCase( + "oid_with_tz", + doc={"s": oid_from_args(2024, 1, 1, 0, 0, 0), "e": oid_from_args(2024, 1, 6, 0, 0, 0)}, + expression={ + "$dateDiff": {"startDate": "$s", "endDate": "$e", "unit": "day", "timezone": "UTC"} + }, + expected=Int64(5), + msg="$dateDiff should count days between ObjectId dates with a timezone", + ), + ExpressionTestCase( + "oid_negative", + doc={"s": oid_from_args(2024, 6, 1, 0, 0, 0), "e": oid_from_args(2024, 1, 1, 0, 0, 0)}, + expression={"$dateDiff": {"startDate": "$s", "endDate": "$e", "unit": "month"}}, + expected=Int64(-5), + msg="$dateDiff should return a negative month difference between ObjectId dates", + ), + ExpressionTestCase( + "oid_endDate", + doc={"e": oid_from_args(2024, 1, 6, 0, 0, 0)}, + expression={ + "$dateDiff": { + "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "endDate": "$e", + "unit": "day", + } + }, + expected=Int64(5), + msg="$dateDiff should accept an ObjectId endDate", + ), + ExpressionTestCase( + "oid_epoch", + doc={"s": oid_from_args(1970, 1, 1, 0, 0, 0)}, + expression={ + "$dateDiff": { + "startDate": "$s", + "endDate": datetime(1970, 1, 2, tzinfo=timezone.utc), + "unit": "day", + } + }, + expected=Int64(1), + msg="$dateDiff should accept an epoch ObjectId startDate", + ), + ExpressionTestCase( + "oid_far_future", + doc={"s": oid_from_args(2035, 1, 1, 0, 0, 0)}, + expression={ + "$dateDiff": { + "startDate": "$s", + "endDate": datetime(2035, 6, 1, tzinfo=timezone.utc), + "unit": "month", + } + }, + expected=Int64(5), + msg="$dateDiff should accept a far-future ObjectId startDate", + ), +] + +# Property [Timestamp Source]: a Timestamp date operand uses its seconds for either position +# and for the timezone option. +DATEDIFF_TIMESTAMP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "ts_day", + doc={"s": ts_from_args(2024, 1, 1, 0, 0, 0), "e": ts_from_args(2024, 1, 6, 0, 0, 0)}, + expression={"$dateDiff": {"startDate": "$s", "endDate": "$e", "unit": "day"}}, + expected=Int64(5), + msg="$dateDiff should count days between two Timestamp dates", + ), + ExpressionTestCase( + "ts_hour", + doc={"s": ts_from_args(2024, 1, 1, 0, 0, 0)}, + expression={ + "$dateDiff": { + "startDate": "$s", + "endDate": datetime(2024, 1, 1, 5, 0, 0, tzinfo=timezone.utc), + "unit": "hour", + } + }, + expected=Int64(5), + msg="$dateDiff should count hours from a Timestamp startDate", + ), + ExpressionTestCase( + "ts_month", + doc={"s": ts_from_args(2024, 1, 1, 0, 0, 0), "e": ts_from_args(2024, 6, 1, 0, 0, 0)}, + expression={"$dateDiff": {"startDate": "$s", "endDate": "$e", "unit": "month"}}, + expected=Int64(5), + msg="$dateDiff should count months between two Timestamp dates", + ), + ExpressionTestCase( + "ts_year", + doc={ + "s": ts_from_args(2024, 1, 1, 0, 0, 0), + "e": ts_from_args(2024, 12, 31, 23, 59, 59), + }, + expression={"$dateDiff": {"startDate": "$s", "endDate": "$e", "unit": "year"}}, + expected=INT64_ZERO, + msg="$dateDiff should count zero years within the same Timestamp year", + ), + ExpressionTestCase( + "ts_with_tz", + doc={"s": ts_from_args(2024, 1, 1, 0, 0, 0), "e": ts_from_args(2024, 1, 6, 0, 0, 0)}, + expression={ + "$dateDiff": {"startDate": "$s", "endDate": "$e", "unit": "day", "timezone": "UTC"} + }, + expected=Int64(5), + msg="$dateDiff should count days between Timestamp dates with a timezone", + ), + ExpressionTestCase( + "ts_negative", + doc={"s": ts_from_args(2024, 6, 1, 0, 0, 0), "e": ts_from_args(2024, 1, 1, 0, 0, 0)}, + expression={"$dateDiff": {"startDate": "$s", "endDate": "$e", "unit": "month"}}, + expected=Int64(-5), + msg="$dateDiff should return a negative month difference between Timestamp dates", + ), + ExpressionTestCase( + "ts_epoch", + doc={"s": ts_from_args(1970, 1, 1, 0, 0, 0)}, + expression={ + "$dateDiff": { + "startDate": "$s", + "endDate": datetime(1970, 1, 2, tzinfo=timezone.utc), + "unit": "day", + } + }, + expected=Int64(1), + msg="$dateDiff should accept an epoch Timestamp startDate", + ), + ExpressionTestCase( + "ts_far_future", + doc={"s": ts_from_args(2100, 1, 1, 0, 0, 0)}, + expression={ + "$dateDiff": { + "startDate": "$s", + "endDate": datetime(2100, 6, 1, tzinfo=timezone.utc), + "unit": "month", + } + }, + expected=Int64(5), + msg="$dateDiff should accept a far-future Timestamp startDate", + ), +] + +# Property [Mixed Source]: ObjectId and Timestamp date operands can be combined. +DATEDIFF_MIXED_SOURCE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "oid_ts_mixed", + doc={"s": oid_from_args(2024, 1, 1, 0, 0, 0), "e": ts_from_args(2024, 1, 6, 0, 0, 0)}, + expression={"$dateDiff": {"startDate": "$s", "endDate": "$e", "unit": "day"}}, + expected=Int64(5), + msg="$dateDiff should count days from an ObjectId startDate to a Timestamp endDate", + ), + ExpressionTestCase( + "ts_oid_mixed", + doc={"s": ts_from_args(2024, 1, 1, 0, 0, 0), "e": oid_from_args(2024, 6, 1, 0, 0, 0)}, + expression={"$dateDiff": {"startDate": "$s", "endDate": "$e", "unit": "month"}}, + expected=Int64(5), + msg="$dateDiff should count months from a Timestamp startDate to an ObjectId endDate", + ), +] + +DATEDIFF_INPUT_TYPE_TESTS: list[ExpressionTestCase] = ( + DATEDIFF_OBJECTID_TESTS + DATEDIFF_TIMESTAMP_TESTS + DATEDIFF_MIXED_SOURCE_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATEDIFF_INPUT_TYPE_TESTS)) +def test_dateDiff_input_types(collection, test_case: ExpressionTestCase): + """Test $dateDiff accepts ObjectId, Timestamp, and mixed date-like operands.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_null.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_null.py new file mode 100644 index 000000000..462b483f3 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_null.py @@ -0,0 +1,135 @@ +"""$dateDiff null and missing propagation: a null or missing operand returns null.""" + +from datetime import datetime, timezone + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + MISSING, +) + +# Property [Null Handling]: a null literal for startDate, endDate, or unit returns null. +DATEDIFF_NULL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_startDate", + expression={ + "$dateDiff": { + "startDate": None, + "endDate": datetime(2024, 6, 1, tzinfo=timezone.utc), + "unit": "day", + } + }, + expected=None, + msg="$dateDiff should return null for a null startDate", + ), + ExpressionTestCase( + "null_endDate", + expression={ + "$dateDiff": { + "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "endDate": None, + "unit": "day", + } + }, + expected=None, + msg="$dateDiff should return null for a null endDate", + ), + ExpressionTestCase( + "null_unit", + expression={ + "$dateDiff": { + "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2024, 6, 1, tzinfo=timezone.utc), + "unit": None, + } + }, + expected=None, + msg="$dateDiff should return null for a null unit", + ), +] + +# Property [Missing Field Reference]: a missing startDate, endDate, unit, timezone, or +# startOfWeek field reference returns null. +DATEDIFF_MISSING_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "missing_startDate_ref", + expression={ + "$dateDiff": { + "startDate": MISSING, + "endDate": datetime(2024, 6, 1, tzinfo=timezone.utc), + "unit": "day", + } + }, + expected=None, + msg="$dateDiff should return null for a missing startDate field reference", + ), + ExpressionTestCase( + "missing_endDate_ref", + expression={ + "$dateDiff": { + "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "endDate": MISSING, + "unit": "day", + } + }, + expected=None, + msg="$dateDiff should return null for a missing endDate field reference", + ), + ExpressionTestCase( + "missing_unit_ref", + expression={ + "$dateDiff": { + "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2024, 6, 1, tzinfo=timezone.utc), + "unit": MISSING, + } + }, + expected=None, + msg="$dateDiff should return null for a missing unit field reference", + ), + ExpressionTestCase( + "missing_timezone_ref", + expression={ + "$dateDiff": { + "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2024, 6, 1, tzinfo=timezone.utc), + "unit": "day", + "timezone": MISSING, + } + }, + expected=None, + msg="$dateDiff should return null for a missing timezone field reference", + ), + ExpressionTestCase( + "missing_startOfWeek_ref", + expression={ + "$dateDiff": { + "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2024, 6, 1, tzinfo=timezone.utc), + "unit": "week", + "startOfWeek": MISSING, + } + }, + expected=None, + msg="$dateDiff should return null for a missing startOfWeek field reference", + ), +] + +DATEDIFF_NULL_MISSING_TESTS: list[ExpressionTestCase] = DATEDIFF_NULL_TESTS + DATEDIFF_MISSING_TESTS + + +@pytest.mark.parametrize("test_case", pytest_params(DATEDIFF_NULL_MISSING_TESTS)) +def test_dateDiff_null(collection, test_case: ExpressionTestCase): + """Test $dateDiff returns null for null literals and missing field references.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_range.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_range.py new file mode 100644 index 000000000..5d50a7eae --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_range.py @@ -0,0 +1,165 @@ +"""$dateDiff across the representable date range: epoch-crossing and extreme boundary values.""" + +from datetime import datetime, timezone + +import pytest +from bson import Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DATE_EPOCH, + DATE_MS_BEFORE_EPOCH, + DATE_MS_EPOCH, + DATE_MS_MAX, + DATE_MS_MIN, + DATE_YEAR_1900, + OID_MAX_SIGNED32, + OID_MIN_SIGNED32, + TS_MAX_SIGNED32, + TS_MAX_UNSIGNED32, +) + +# Property [Epoch Crossing]: dates around the Unix epoch and far from it are counted correctly. +DATEDIFF_EPOCH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "epoch_date", + doc={"s": DATE_EPOCH}, + expression={ + "$dateDiff": { + "startDate": "$s", + "endDate": datetime(1970, 1, 2, tzinfo=timezone.utc), + "unit": "day", + } + }, + expected=Int64(1), + msg="$dateDiff should count one day from the epoch", + ), + ExpressionTestCase( + "pre_epoch", + expression={ + "$dateDiff": { + "startDate": datetime(1969, 12, 31, tzinfo=timezone.utc), + "endDate": DATE_EPOCH, + "unit": "day", + } + }, + expected=Int64(1), + msg="$dateDiff should count one day across the epoch from 1969", + ), + ExpressionTestCase( + "distant_past", + doc={"s": DATE_YEAR_1900}, + expression={ + "$dateDiff": { + "startDate": "$s", + "endDate": datetime(1900, 12, 31, tzinfo=timezone.utc), + "unit": "day", + } + }, + expected=Int64(364), + msg="$dateDiff should count days within a distant past year", + ), + ExpressionTestCase( + "distant_future", + expression={ + "$dateDiff": { + "startDate": datetime(2100, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2100, 12, 31, tzinfo=timezone.utc), + "unit": "day", + } + }, + expected=Int64(364), + msg="$dateDiff should count days within a distant future year", + ), + ExpressionTestCase( + "cross_epoch", + expression={ + "$dateDiff": { + "startDate": datetime(1969, 6, 1, tzinfo=timezone.utc), + "endDate": datetime(1970, 6, 1, tzinfo=timezone.utc), + "unit": "year", + } + }, + expected=Int64(1), + msg="$dateDiff should count one year crossing the epoch", + ), +] + +# Property [Extreme Boundary]: DatetimeMS, Timestamp, and ObjectId values at the edges of the +# 32-bit and 64-bit ranges are counted correctly. +DATEDIFF_EXTREME_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "date_ms_epoch_diff", + doc={"s": DATE_MS_EPOCH}, + expression={ + "$dateDiff": { + "startDate": "$s", + "endDate": datetime(1970, 1, 2, tzinfo=timezone.utc), + "unit": "day", + } + }, + expected=Int64(1), + msg="$dateDiff should count one day from an epoch DatetimeMS", + ), + ExpressionTestCase( + "date_ms_before_epoch_diff", + doc={"s": DATE_MS_BEFORE_EPOCH}, + expression={"$dateDiff": {"startDate": "$s", "endDate": DATE_EPOCH, "unit": "millisecond"}}, + expected=Int64(1), + msg="$dateDiff should count one millisecond from just before the epoch", + ), + ExpressionTestCase( + "ts_boundary_max_s32", + doc={"s": TS_MAX_SIGNED32, "e": TS_MAX_UNSIGNED32}, + expression={"$dateDiff": {"startDate": "$s", "endDate": "$e", "unit": "year"}}, + expected=Int64(68), + msg="$dateDiff should count years between the signed and unsigned 32-bit Timestamp limits", + ), + ExpressionTestCase( + "oid_max_signed32", + doc={"s": OID_MAX_SIGNED32}, + expression={ + "$dateDiff": { + "startDate": "$s", + "endDate": datetime(2038, 1, 20, tzinfo=timezone.utc), + "unit": "day", + } + }, + expected=Int64(1), + msg="$dateDiff should count from a max signed 32-bit ObjectId", + ), + ExpressionTestCase( + "oid_high_bit_pre_epoch", + doc={"s": OID_MIN_SIGNED32}, + expression={"$dateDiff": {"startDate": "$s", "endDate": DATE_EPOCH, "unit": "year"}}, + expected=Int64(69), + msg="$dateDiff should handle an ObjectId with the high timestamp bit set as pre-epoch", + ), + ExpressionTestCase( + "ms_overflow_int64", + doc={"s": DATE_MS_MIN, "e": DATE_MS_MAX}, + expression={"$dateDiff": {"startDate": "$s", "endDate": "$e", "unit": "year"}}, + expected=Int64(584_554_049), + msg="$dateDiff should count years between the minimum and maximum representable dates", + ), +] + +DATEDIFF_RANGE_TESTS: list[ExpressionTestCase] = ( + DATEDIFF_EPOCH_TESTS + DATEDIFF_EXTREME_BOUNDARY_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATEDIFF_RANGE_TESTS)) +def test_dateDiff_range(collection, test_case: ExpressionTestCase): + """Test $dateDiff counts correctly across epoch and extreme boundary dates.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_timezone.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_timezone.py new file mode 100644 index 000000000..d55e12200 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_timezone.py @@ -0,0 +1,292 @@ +"""$dateDiff timezone handling: offsets, DST, startOfWeek values, and unit gating.""" + +from datetime import datetime, timezone + +import pytest +from bson import Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import INT64_ZERO + +# Property [Valid Timezone]: a valid Olson id or syntactically valid UTC offset is accepted, +# including numerically out-of-range but well-formed offsets. +DATEDIFF_TIMEZONE_VALID_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"tz_{tid}", + expression={ + "$dateDiff": { + "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2024, 6, 1, tzinfo=timezone.utc), + "unit": "day", + "timezone": tz, + } + }, + expected=Int64(152), + msg=f"$dateDiff should accept the {tz} timezone", + ) + for tid, tz in [ + ("utc", "UTC"), + ("gmt", "GMT"), + ("olson_ny", "America/New_York"), + ("asia_kolkata", "Asia/Kolkata"), + ("pacific_apia", "Pacific/Apia"), + ("offset_colon", "+05:30"), + ("offset_neg", "-05:00"), + ("offset_no_colon", "+0530"), + ("offset_short", "+03"), + ("offset_zero", "+00:00"), + ("offset_45min", "+05:45"), + ("offset_max_east", "+14:00"), + ("offset_max_west", "-11:00"), + ("offset_half_hour_west", "-02:30"), + ("offset_over60_minutes_positive", "+05:70"), + ("offset_over60_minutes_negative", "-05:70"), + ("offset_over24_hours_positive", "+25:00"), + ("offset_over24_hours_negative", "-25:00"), + ("offset_max_valid_positive", "+99:99"), + ("offset_max_valid_negative", "-99:99"), + ] +] + +# Property [Timezone Boundary Effect]: the timezone shifts the local wall clock, changing +# which unit boundaries are crossed. +DATEDIFF_TIMEZONE_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_plus14_year", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 12, 31, 11, 0, 0, tzinfo=timezone.utc), + "endDate": datetime(2022, 1, 1, 11, 0, 0, tzinfo=timezone.utc), + "unit": "year", + "timezone": "+14:00", + } + }, + expected=INT64_ZERO, + msg="$dateDiff should not cross the year boundary in a +14:00 timezone", + ), + ExpressionTestCase( + "tz_minus11_day", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 1, 1, 10, 0, 0, tzinfo=timezone.utc), + "endDate": datetime(2021, 1, 2, 10, 0, 0, tzinfo=timezone.utc), + "unit": "day", + "timezone": "-11:00", + } + }, + expected=Int64(1), + msg="$dateDiff should cross the day boundary in a -11:00 timezone", + ), + ExpressionTestCase( + "tz_hour_only_day", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + "endDate": datetime(2021, 1, 2, 0, 0, 0, tzinfo=timezone.utc), + "unit": "day", + "timezone": "+03", + } + }, + expected=Int64(1), + msg="$dateDiff should cross the day boundary in an hour-only offset timezone", + ), + ExpressionTestCase( + "tz_year_boundary", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 12, 31, 23, 0, 0, tzinfo=timezone.utc), + "endDate": datetime(2022, 1, 1, 1, 0, 0, tzinfo=timezone.utc), + "unit": "year", + "timezone": "+02:00", + } + }, + expected=INT64_ZERO, + msg="$dateDiff should not cross the year boundary when the timezone shifts both dates " + "into the same year", + ), + ExpressionTestCase( + "tz_day_boundary", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 1, 1, 23, 30, 0, tzinfo=timezone.utc), + "endDate": datetime(2021, 1, 2, 0, 30, 0, tzinfo=timezone.utc), + "unit": "day", + "timezone": "+05:30", + } + }, + expected=INT64_ZERO, + msg="$dateDiff should not cross the day boundary when the timezone shifts both dates " + "into the same day", + ), +] + +# Property [DST Behavior]: day and larger units track wall-clock time across DST, while +# sub-day units count absolute elapsed time. +DATEDIFF_TIMEZONE_DST_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "dst_spring_hour", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 3, 14, 6, 0, 0, tzinfo=timezone.utc), + "endDate": datetime(2021, 3, 14, 8, 0, 0, tzinfo=timezone.utc), + "unit": "hour", + "timezone": "America/New_York", + } + }, + expected=Int64(2), + msg="$dateDiff should count absolute hours across spring-forward", + ), + ExpressionTestCase( + "dst_fall_hour", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 11, 7, 5, 0, 0, tzinfo=timezone.utc), + "endDate": datetime(2021, 11, 7, 7, 0, 0, tzinfo=timezone.utc), + "unit": "hour", + "timezone": "America/New_York", + } + }, + expected=Int64(2), + msg="$dateDiff should count absolute hours across fall-back", + ), + ExpressionTestCase( + "dst_day_unaffected", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 3, 13, 12, 0, 0, tzinfo=timezone.utc), + "endDate": datetime(2021, 3, 15, 12, 0, 0, tzinfo=timezone.utc), + "unit": "day", + "timezone": "America/New_York", + } + }, + expected=Int64(2), + msg="$dateDiff should count two days across a DST transition", + ), + ExpressionTestCase( + "dst_spring_minute_no_adjust", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 3, 14, 6, 59, 0, tzinfo=timezone.utc), + "endDate": datetime(2021, 3, 14, 7, 0, 0, tzinfo=timezone.utc), + "unit": "minute", + "timezone": "America/New_York", + } + }, + expected=Int64(1), + msg="$dateDiff should not DST-adjust a minute count", + ), + ExpressionTestCase( + "dst_spring_second_no_adjust", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 3, 14, 6, 59, 59, tzinfo=timezone.utc), + "endDate": datetime(2021, 3, 14, 7, 0, 0, tzinfo=timezone.utc), + "unit": "second", + "timezone": "America/New_York", + } + }, + expected=Int64(1), + msg="$dateDiff should not DST-adjust a second count", + ), +] + +# Property [StartOfWeek Value]: startOfWeek accepts full and abbreviated day names +# case-insensitively. +DATEDIFF_STARTOFWEEK_VALUE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"sow_{tid}", + expression={ + "$dateDiff": { + "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2024, 1, 31, tzinfo=timezone.utc), + "unit": "week", + "startOfWeek": sow, + } + }, + expected=Int64(4), + msg=f"$dateDiff should accept the {sow} startOfWeek value", + ) + for tid, sow in [ + ("mixed_case_Monday", "Monday"), + ("uppercase_MONDAY", "MONDAY"), + ("lowercase_monday", "monday"), + ("abbrev_mon", "mon"), + ("abbrev_MON", "MON"), + ("mixed_case_Friday", "Friday"), + ("uppercase_FRIDAY", "FRIDAY"), + ("abbrev_FRI", "FRI"), + ] +] + +# Property [Null Timezone]: a null timezone returns null. +DATEDIFF_TIMEZONE_NULL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_null", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2021, 1, 2, tzinfo=timezone.utc), + "unit": "day", + "timezone": None, + } + }, + expected=None, + msg="$dateDiff should return null when the timezone is null", + ), +] + +# Property [StartOfWeek Unit Gating]: startOfWeek is only consulted for the week unit, so with +# any other unit it is ignored and not even validated. +DATEDIFF_STARTOFWEEK_GATING_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "sow_ignored_day_unit", + expression={ + "$dateDiff": { + "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2024, 1, 31, tzinfo=timezone.utc), + "unit": "day", + "startOfWeek": "monday", + } + }, + expected=Int64(30), + msg="$dateDiff should ignore startOfWeek for a non-week unit", + ), + ExpressionTestCase( + "sow_invalid_ignored_day_unit", + expression={ + "$dateDiff": { + "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2024, 1, 31, tzinfo=timezone.utc), + "unit": "day", + "startOfWeek": "notaday", + } + }, + expected=Int64(30), + msg="$dateDiff should not validate startOfWeek for a non-week unit", + ), +] + +DATEDIFF_TIMEZONE_TESTS: list[ExpressionTestCase] = ( + DATEDIFF_TIMEZONE_VALID_TESTS + + DATEDIFF_TIMEZONE_BOUNDARY_TESTS + + DATEDIFF_TIMEZONE_DST_TESTS + + DATEDIFF_STARTOFWEEK_VALUE_TESTS + + DATEDIFF_TIMEZONE_NULL_TESTS + + DATEDIFF_STARTOFWEEK_GATING_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATEDIFF_TIMEZONE_TESTS)) +def test_dateDiff_timezone(collection, test_case: ExpressionTestCase): + """Test $dateDiff applies timezone and startOfWeek to the local wall clock.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_week.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_week.py new file mode 100644 index 000000000..a6cb80d06 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_week.py @@ -0,0 +1,167 @@ +"""$dateDiff week counting: week boundaries crossed relative to startOfWeek.""" + +from datetime import datetime, timezone + +import pytest +from bson import Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Week Counting]: the difference counts week boundaries crossed relative to +# startOfWeek, defaulting to Sunday. +DATEDIFF_WEEK_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "jan_default_sun", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2021, 1, 31, tzinfo=timezone.utc), + "unit": "week", + } + }, + expected=Int64(5), + msg="$dateDiff should count January weeks from the default Sunday start", + ), + ExpressionTestCase( + "jan_monday", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2021, 1, 31, tzinfo=timezone.utc), + "unit": "week", + "startOfWeek": "monday", + } + }, + expected=Int64(4), + msg="$dateDiff should count January weeks from a Monday start", + ), + ExpressionTestCase( + "jan_friday", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2021, 1, 31, tzinfo=timezone.utc), + "unit": "week", + "startOfWeek": "fri", + } + }, + expected=Int64(4), + msg="$dateDiff should count January weeks from a Friday start", + ), + ExpressionTestCase( + "feb_default_sun", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 2, 1, tzinfo=timezone.utc), + "endDate": datetime(2021, 2, 28, tzinfo=timezone.utc), + "unit": "week", + } + }, + expected=Int64(4), + msg="$dateDiff should count February weeks from the default Sunday start", + ), + ExpressionTestCase( + "feb_monday", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 2, 1, tzinfo=timezone.utc), + "endDate": datetime(2021, 2, 28, tzinfo=timezone.utc), + "unit": "week", + "startOfWeek": "monday", + } + }, + expected=Int64(3), + msg="$dateDiff should count February weeks from a Monday start", + ), + ExpressionTestCase( + "feb_friday", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 2, 1, tzinfo=timezone.utc), + "endDate": datetime(2021, 2, 28, tzinfo=timezone.utc), + "unit": "week", + "startOfWeek": "fri", + } + }, + expected=Int64(4), + msg="$dateDiff should count February weeks from a Friday start", + ), + ExpressionTestCase( + "mar_default_sun", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 3, 1, tzinfo=timezone.utc), + "endDate": datetime(2021, 3, 31, tzinfo=timezone.utc), + "unit": "week", + } + }, + expected=Int64(4), + msg="$dateDiff should count March weeks from the default Sunday start", + ), + ExpressionTestCase( + "mar_monday", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 3, 1, tzinfo=timezone.utc), + "endDate": datetime(2021, 3, 31, tzinfo=timezone.utc), + "unit": "week", + "startOfWeek": "monday", + } + }, + expected=Int64(4), + msg="$dateDiff should count March weeks from a Monday start", + ), + ExpressionTestCase( + "mar_friday", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 3, 1, tzinfo=timezone.utc), + "endDate": datetime(2021, 3, 31, tzinfo=timezone.utc), + "unit": "week", + "startOfWeek": "fri", + } + }, + expected=Int64(4), + msg="$dateDiff should count March weeks from a Friday start", + ), + ExpressionTestCase( + "cross_year_week", + expression={ + "$dateDiff": { + "startDate": datetime(2020, 12, 28, tzinfo=timezone.utc), + "endDate": datetime(2021, 1, 4, tzinfo=timezone.utc), + "unit": "week", + } + }, + expected=Int64(1), + msg="$dateDiff should count one week across the year boundary", + ), + ExpressionTestCase( + "cross_month_week", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 1, 25, tzinfo=timezone.utc), + "endDate": datetime(2021, 2, 8, tzinfo=timezone.utc), + "unit": "week", + } + }, + expected=Int64(2), + msg="$dateDiff should count two weeks across the month boundary", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(DATEDIFF_WEEK_TESTS)) +def test_dateDiff_week(collection, test_case: ExpressionTestCase): + """Test $dateDiff counts week boundaries relative to the configured start of week.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_arithmetic.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_arithmetic.py new file mode 100644 index 000000000..e5b377832 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_arithmetic.py @@ -0,0 +1,290 @@ +"""$dateSubtract core arithmetic: unit application, sign, amount types, and unit equivalence.""" + +from datetime import datetime, timezone + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_NEGATIVE_ZERO, + DOUBLE_NEGATIVE_ZERO, + INT32_MAX, + INT32_MIN, +) + +# Property [Unit Arithmetic]: subtracting a positive amount moves the start date back by that unit. +DATESUBTRACT_UNIT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "year_subtract", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "year", "amount": 1}}, + expected=datetime(1999, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should subtract 1 year", + ), + ExpressionTestCase( + "month_subtract", + doc={"date": datetime(2000, 3, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "month", "amount": 2}}, + expected=datetime(2000, 1, 15, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should subtract 2 months", + ), + ExpressionTestCase( + "day_subtract", + doc={"date": datetime(2000, 1, 11, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 10}}, + expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should subtract 10 days", + ), + ExpressionTestCase( + "hour_subtract", + doc={"date": datetime(2000, 1, 1, 17, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "hour", "amount": 5}}, + expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should subtract 5 hours", + ), + ExpressionTestCase( + "minute_subtract", + doc={"date": datetime(2000, 1, 1, 12, 30, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "minute", "amount": 30}}, + expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should subtract 30 minutes", + ), + ExpressionTestCase( + "second_subtract", + doc={"date": datetime(2000, 1, 1, 12, 0, 45, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "second", "amount": 45}}, + expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should subtract 45 seconds", + ), + ExpressionTestCase( + "millisecond_subtract", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, 500000, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "millisecond", "amount": 500}}, + expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should subtract 500 milliseconds", + ), + ExpressionTestCase( + "week_subtract", + doc={"date": datetime(2000, 1, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "week", "amount": 2}}, + expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should subtract 2 weeks", + ), + ExpressionTestCase( + "quarter_subtract", + doc={"date": datetime(2000, 4, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "quarter", "amount": 1}}, + expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should subtract 1 quarter", + ), +] + +# Property [Negative Amount]: a negative amount advances the start date forward by the given unit. +DATESUBTRACT_NEGATIVE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "year_add", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "year", "amount": -1}}, + expected=datetime(2001, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should add 1 year with a negative amount", + ), + ExpressionTestCase( + "month_add", + doc={"date": datetime(2000, 1, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "month", "amount": -2}}, + expected=datetime(2000, 3, 15, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should add 2 months with a negative amount", + ), + ExpressionTestCase( + "day_add", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": -10}}, + expected=datetime(2000, 1, 11, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should add 10 days with a negative amount", + ), +] + +# Property [Zero Amount]: a zero amount for any unit returns the start date unchanged. +DATESUBTRACT_ZERO_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"zero_amount_{unit}", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": unit, "amount": 0}}, + expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg=f"$dateSubtract should return the start date unchanged for a zero {unit} amount", + ) + for unit in ( + "year", + "quarter", + "month", + "week", + "day", + "hour", + "minute", + "second", + "millisecond", + ) +] + +# Property [Amount Numeric Types]: integral int64, double, and decimal128 amounts, including +# negative zero, are accepted. +DATESUBTRACT_AMOUNT_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "amount_int64", + doc={"date": datetime(2000, 1, 6, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": Int64(5)}}, + expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should accept an int64 amount", + ), + ExpressionTestCase( + "amount_double_integral", + doc={"date": datetime(2000, 1, 6, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 5.0}}, + expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should accept an integral double amount", + ), + ExpressionTestCase( + "amount_decimal", + doc={"date": datetime(2000, 1, 6, 12, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateSubtract": {"startDate": "$date", "unit": "day", "amount": Decimal128("5")} + }, + expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should accept an integral decimal128 amount", + ), + ExpressionTestCase( + "amount_double_negative_zero", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateSubtract": {"startDate": "$date", "unit": "day", "amount": DOUBLE_NEGATIVE_ZERO} + }, + expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should treat a negative-zero double as a zero amount", + ), + ExpressionTestCase( + "amount_decimal128_negative_zero", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateSubtract": { + "startDate": "$date", + "unit": "day", + "amount": DECIMAL128_NEGATIVE_ZERO, + } + }, + expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should treat a negative-zero decimal128 as a zero amount", + ), + ExpressionTestCase( + "amount_decimal128_max_precision_integral", + doc={"date": datetime(2000, 1, 4, 12, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateSubtract": { + "startDate": "$date", + "unit": "day", + "amount": Decimal128("3.0000000000000000000000000000000000"), + } + }, + expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should accept a max-precision integral decimal128 amount", + ), +] + +# Property [Amount Boundary]: large valid int32/int64 amounts are accepted. +DATESUBTRACT_AMOUNT_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "amount_long_large", + doc={"date": datetime(2069, 9, 18, 11, 6, 40, tzinfo=timezone.utc)}, + expression={ + "$dateSubtract": { + "startDate": "$date", + "unit": "second", + "amount": Int64(2_200_000_000), + } + }, + expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should accept a large int64 amount", + ), + ExpressionTestCase( + "amount_int32_max", + doc={"date": datetime(2000, 1, 26, 8, 31, 23, 647000, tzinfo=timezone.utc)}, + expression={ + "$dateSubtract": {"startDate": "$date", "unit": "millisecond", "amount": INT32_MAX} + }, + expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should accept INT32_MAX milliseconds", + ), + ExpressionTestCase( + "amount_int32_min", + doc={"date": datetime(1999, 12, 7, 15, 28, 36, 352000, tzinfo=timezone.utc)}, + expression={ + "$dateSubtract": {"startDate": "$date", "unit": "millisecond", "amount": INT32_MIN} + }, + expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should accept INT32_MIN milliseconds", + ), +] + +# Property [Unit Equivalence]: a smaller unit times its multiple equals the larger unit. +DATESUBTRACT_UNIT_EQUIVALENCE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "millisecond_1000_equals_second_1", + doc={"date": datetime(2000, 1, 1, 12, 0, 1, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "millisecond", "amount": 1000}}, + expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract of 1000 milliseconds should equal subtracting 1 second", + ), + ExpressionTestCase( + "millisecond_precision_1ms", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, 1000, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "millisecond", "amount": 1}}, + expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should decrement by exactly 1 millisecond", + ), + ExpressionTestCase( + "day_7_equals_week", + doc={"date": datetime(2000, 6, 8, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 7}}, + expected=datetime(2000, 6, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract of 7 days should equal subtracting 1 week", + ), + ExpressionTestCase( + "month_12_equals_year", + doc={"date": datetime(2001, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "month", "amount": 12}}, + expected=datetime(2000, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract of 12 months should equal subtracting 1 year", + ), + ExpressionTestCase( + "month_3_equals_quarter", + doc={"date": datetime(2000, 9, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "month", "amount": 3}}, + expected=datetime(2000, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract of 3 months should equal subtracting 1 quarter", + ), +] + +DATESUBTRACT_ARITHMETIC_TESTS: list[ExpressionTestCase] = ( + DATESUBTRACT_UNIT_TESTS + + DATESUBTRACT_NEGATIVE_TESTS + + DATESUBTRACT_ZERO_TESTS + + DATESUBTRACT_AMOUNT_TYPE_TESTS + + DATESUBTRACT_AMOUNT_BOUNDARY_TESTS + + DATESUBTRACT_UNIT_EQUIVALENCE_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATESUBTRACT_ARITHMETIC_TESTS)) +def test_dateSubtract_arithmetic(collection, test_case: ExpressionTestCase): + """Test $dateSubtract arithmetic across units, amount signs, and amount types.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_calendar.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_calendar.py new file mode 100644 index 000000000..3f0d74b0c --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_calendar.py @@ -0,0 +1,198 @@ +"""$dateSubtract calendar rules: leap years, century rule, month clamping, and boundary carry.""" + +from datetime import datetime, timezone + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DATE_Y2K, +) + +# Property [Leap Year]: subtracting across February resolves leap-year day counts correctly. +DATESUBTRACT_LEAP_YEAR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "leap_year_feb28_sub_year", + doc={"date": datetime(2001, 2, 28, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "year", "amount": 1}}, + expected=datetime(2000, 2, 28, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should land on Feb 28 when subtracting a year into a leap year", + ), + ExpressionTestCase( + "leap_year_feb29_sub_year", + doc={"date": datetime(2020, 2, 29, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "year", "amount": 1}}, + expected=datetime(2019, 2, 28, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should clamp Feb 29 to Feb 28 subtracting a year into a non-leap year", + ), + ExpressionTestCase( + "leap_year_sub_day_to_feb29", + doc={"date": datetime(2000, 3, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=datetime(2000, 2, 29, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should land on Feb 29 in a leap year", + ), + ExpressionTestCase( + "non_leap_year_mar1_sub_day", + doc={"date": datetime(1999, 3, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=datetime(1999, 2, 28, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should land on Feb 28 in a non-leap year", + ), + ExpressionTestCase( + "leap_year_mar29_sub_month", + doc={"date": datetime(2020, 3, 29, 10, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "month", "amount": 1}}, + expected=datetime(2020, 2, 29, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should subtract a month from Mar 29 to Feb 29 in a leap year", + ), + ExpressionTestCase( + "leap_year_365_days", + doc={"date": datetime(2020, 12, 31, 15, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 365}}, + expected=datetime(2020, 1, 1, 15, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should not wrap the year when subtracting 365 days in a leap year", + ), + ExpressionTestCase( + "non_leap_year_365_days", + doc={"date": datetime(2020, 1, 1, 15, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 365}}, + expected=datetime(2019, 1, 1, 15, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should wrap to the prior year subtracting 365 days into a non-leap year", + ), +] + +# Property [Century Leap Year]: the divisible-by-100/400 leap rule is honored. +DATESUBTRACT_CENTURY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "century_non_leap_1900_mar1_sub_day", + doc={"date": datetime(1900, 3, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=datetime(1900, 2, 28, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should land on Feb 28 in 1900, a non-leap century year", + ), + ExpressionTestCase( + "century_leap_2000_mar1_sub_day", + doc={"date": datetime(2000, 3, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=datetime(2000, 2, 29, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should land on Feb 29 in 2000, a leap century year", + ), +] + +# Property [Month Clamping]: subtracting months or quarters clamps to the last valid day of the +# target month. +DATESUBTRACT_MONTH_CLAMP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "mar31_sub_month_leap", + doc={"date": datetime(2000, 3, 31, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "month", "amount": 1}}, + expected=datetime(2000, 2, 29, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should clamp Mar 31 minus 1 month to Feb 29 in a leap year", + ), + ExpressionTestCase( + "mar31_sub_month_non_leap", + doc={"date": datetime(2021, 3, 31, 10, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "month", "amount": 1}}, + expected=datetime(2021, 2, 28, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should clamp Mar 31 minus 1 month to Feb 28 in a non-leap year", + ), + ExpressionTestCase( + "may31_sub_month", + doc={"date": datetime(2000, 5, 31, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "month", "amount": 1}}, + expected=datetime(2000, 4, 30, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should clamp May 31 minus 1 month to Apr 30", + ), + ExpressionTestCase( + "jul31_sub_month", + doc={"date": datetime(2000, 7, 31, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "month", "amount": 1}}, + expected=datetime(2000, 6, 30, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should clamp Jul 31 minus 1 month to Jun 30", + ), + ExpressionTestCase( + "jan31_sub_month_no_adjustment", + doc={"date": datetime(2021, 1, 31, 10, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "month", "amount": 1}}, + expected=datetime(2020, 12, 31, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should not adjust Jan 31 minus 1 month, landing on Dec 31", + ), + ExpressionTestCase( + "dec31_sub_year_no_adjustment", + doc={"date": datetime(2021, 12, 31, 10, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "year", "amount": 1}}, + expected=datetime(2020, 12, 31, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should not adjust Dec 31 when subtracting a year", + ), + ExpressionTestCase( + "apr30_sub_quarter", + doc={"date": datetime(2021, 4, 30, 10, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "quarter", "amount": 1}}, + expected=datetime(2021, 1, 30, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should subtract 1 quarter from Apr 30 to Jan 30", + ), + ExpressionTestCase( + "large_positive_month", + doc={"date": datetime(2020, 12, 31, 12, 10, 5, 10000, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "month", "amount": 49}}, + expected=datetime(2016, 11, 30, 12, 10, 5, 10000, tzinfo=timezone.utc), + msg="$dateSubtract should clamp a large positive month amount to end-of-month", + ), + ExpressionTestCase( + "large_negative_month", + doc={"date": datetime(2020, 12, 31, 12, 10, 5, 10000, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "month", "amount": -50}}, + expected=datetime(2025, 2, 28, 12, 10, 5, 10000, tzinfo=timezone.utc), + msg="$dateSubtract should clamp a large negative month amount to end-of-month", + ), +] + +# Property [Boundary Crossing]: subtracting across day, month, and year boundaries carries +# correctly. +DATESUBTRACT_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "dec31_sub_day", + doc={"date": datetime(2001, 1, 1, 23, 59, 59, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=datetime(2000, 12, 31, 23, 59, 59, tzinfo=timezone.utc), + msg="$dateSubtract should cross the year boundary from Jan 1 minus 1 day", + ), + ExpressionTestCase( + "jan1_add_day", + doc={"date": DATE_Y2K}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": -1}}, + expected=datetime(2000, 1, 2, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should cross the day boundary from Jan 1 with a negative amount", + ), + ExpressionTestCase( + "dec31_sub_hour", + doc={"date": datetime(2001, 1, 1, 1, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "hour", "amount": 2}}, + expected=datetime(2000, 12, 31, 23, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should cross the year boundary from Jan 1 minus 2 hours", + ), +] + +DATESUBTRACT_CALENDAR_TESTS: list[ExpressionTestCase] = ( + DATESUBTRACT_LEAP_YEAR_TESTS + + DATESUBTRACT_CENTURY_TESTS + + DATESUBTRACT_MONTH_CLAMP_TESTS + + DATESUBTRACT_BOUNDARY_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATESUBTRACT_CALENDAR_TESTS)) +def test_dateSubtract_calendar(collection, test_case: ExpressionTestCase): + """Test $dateSubtract honors calendar rules across months, years, and boundaries.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_date_range.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_date_range.py new file mode 100644 index 000000000..56fa7c45d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_date_range.py @@ -0,0 +1,135 @@ +"""$dateSubtract across the representable date range: historical, future, epoch, and limit dates.""" + +from datetime import datetime, timezone + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DATE_EPOCH, + DATE_YEAR_1, + DATE_YEAR_1900, + DATE_YEAR_9999, +) + +# Property [Historical And Future]: distant past and future start dates are handled. +DATESUBTRACT_HISTORICAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "historical_date", + doc={"date": datetime(1910, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "year", "amount": 10}}, + expected=datetime(1900, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should handle a 1910 historical date", + ), + ExpressionTestCase( + "pre_epoch_1960", + doc={"date": datetime(1960, 4, 10, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 100}}, + expected=datetime(1960, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should handle a pre-epoch 1960 date", + ), + ExpressionTestCase( + "far_future", + doc={"date": datetime(2200, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "year", "amount": 100}}, + expected=datetime(2100, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should handle a far-future 2200 date", + ), + ExpressionTestCase( + "large_year_amount", + doc={"date": datetime(3000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "year", "amount": 1000}}, + expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should handle subtracting 1000 years", + ), + ExpressionTestCase( + "distant_past", + doc={"date": datetime(1901, 1, 1, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "year", "amount": 1}}, + expected=DATE_YEAR_1900, + msg="$dateSubtract should handle 1901 minus 1 year", + ), + ExpressionTestCase( + "distant_future_month", + doc={"date": datetime(2100, 7, 1, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "month", "amount": 6}}, + expected=datetime(2100, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should handle 2100 minus 6 months", + ), +] + +# Property [Epoch Crossing]: subtracting forward and backward across the Unix epoch is correct. +DATESUBTRACT_EPOCH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "epoch_sub_day", + doc={"date": datetime(1970, 1, 2, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=DATE_EPOCH, + msg="$dateSubtract should subtract a day to reach the epoch", + ), + ExpressionTestCase( + "cross_epoch_back", + doc={"date": DATE_EPOCH}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=datetime(1969, 12, 31, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should cross the epoch backward to 1969", + ), + ExpressionTestCase( + "pre_epoch_to_epoch", + doc={"date": datetime(1969, 12, 31, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": -1}}, + expected=DATE_EPOCH, + msg="$dateSubtract should cross the epoch forward from 1969 with a negative amount", + ), +] + +# Property [Date Limits]: subtracting near the minimum representable date is handled. +DATESUBTRACT_DATE_LIMIT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "near_min_date", + doc={"date": datetime(1, 1, 1, 0, 0, 1, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "millisecond", "amount": 1}}, + expected=datetime(1, 1, 1, 0, 0, 0, 999000, tzinfo=timezone.utc), + msg="$dateSubtract should subtract 1 millisecond near the minimum date", + ), + ExpressionTestCase( + "year_9999_sub_large_ms", + doc={"date": DATE_YEAR_9999}, + expression={ + "$dateSubtract": { + "startDate": "$date", + "unit": "millisecond", + "amount": 253_402_300_799_999, + } + }, + expected=DATE_EPOCH, + msg="$dateSubtract should reach the epoch from the max date with a large ms amount", + ), + ExpressionTestCase( + "at_python_min_year", + doc={"date": datetime(1, 1, 1, 23, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "hour", "amount": 23}}, + expected=DATE_YEAR_1, + msg="$dateSubtract should subtract 23 hours at year 1", + ), +] + +DATESUBTRACT_DATE_RANGE_TESTS: list[ExpressionTestCase] = ( + DATESUBTRACT_HISTORICAL_TESTS + DATESUBTRACT_EPOCH_TESTS + DATESUBTRACT_DATE_LIMIT_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATESUBTRACT_DATE_RANGE_TESTS)) +def test_dateSubtract_date_range(collection, test_case: ExpressionTestCase): + """Test $dateSubtract remains correct across distant, epoch-crossing, and boundary dates.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_errors.py new file mode 100644 index 000000000..a5ef68659 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_errors.py @@ -0,0 +1,433 @@ +"""$dateSubtract rejection cases: invalid operand types/values, timezone, overflow, and shape.""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + DATEADD_INVALID_AMOUNT_ERROR, + DATEADD_INVALID_LARGE_VALUE_ERROR, + DATEADD_INVALID_STARTDATE_ERROR, + DATEADD_MISSING_FIELD_ERROR, + DATEADD_UNKNOWN_FIELD_ERROR, + FAILED_TO_PARSE_ERROR, + INVALID_DATE_UNIT_ERROR, + INVALID_TIMEZONE_ERROR, + INVALID_TIMEZONE_TYPE_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_ONE_AND_HALF, + DOUBLE_MIN_SUBNORMAL, + DOUBLE_NEAR_MIN, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, +) + +# Property [Amount Non-Integral]: a non-integral numeric amount is rejected. +DATESUBTRACT_AMOUNT_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "amount_non_integral_double_1_5", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 1.5}}, + error_code=DATEADD_INVALID_AMOUNT_ERROR, + msg="$dateSubtract should reject a non-integral double amount", + ), + ExpressionTestCase( + "amount_non_integral_double_5_9", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 5.9}}, + error_code=DATEADD_INVALID_AMOUNT_ERROR, + msg="$dateSubtract should reject a non-integral double amount close to an integer", + ), + ExpressionTestCase( + "amount_non_integral_double_negative", + doc={"date": datetime(2000, 1, 10, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": -5.9}}, + error_code=DATEADD_INVALID_AMOUNT_ERROR, + msg="$dateSubtract should reject a non-integral negative double amount", + ), + ExpressionTestCase( + "amount_non_integral_decimal128", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateSubtract": { + "startDate": "$date", + "unit": "day", + "amount": DECIMAL128_ONE_AND_HALF, + } + }, + error_code=DATEADD_INVALID_AMOUNT_ERROR, + msg="$dateSubtract should reject a non-integral decimal128 amount", + ), + ExpressionTestCase( + "amount_decimal128_non_integral_34th_digit", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateSubtract": { + "startDate": "$date", + "unit": "day", + "amount": Decimal128("3.000000000000000000000000000000001"), + } + }, + error_code=DATEADD_INVALID_AMOUNT_ERROR, + msg="$dateSubtract should reject a decimal128 amount non-integral at the 34th digit", + ), + ExpressionTestCase( + "amount_double_near_min", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateSubtract": {"startDate": "$date", "unit": "day", "amount": DOUBLE_NEAR_MIN} + }, + error_code=DATEADD_INVALID_AMOUNT_ERROR, + msg="$dateSubtract should reject a near-minimum double amount as non-integral", + ), + ExpressionTestCase( + "amount_double_min_subnormal", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateSubtract": {"startDate": "$date", "unit": "day", "amount": DOUBLE_MIN_SUBNORMAL} + }, + error_code=DATEADD_INVALID_AMOUNT_ERROR, + msg="$dateSubtract should reject a minimum subnormal double amount as non-integral", + ), +] + +# Property [StartDate Type]: a non-date, non-Timestamp, non-ObjectId startDate is rejected. +DATESUBTRACT_STARTDATE_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"startDate_{tid}", + doc={"date": val}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 1}}, + error_code=DATEADD_INVALID_STARTDATE_ERROR, + msg=f"$dateSubtract should reject a {tid} startDate", + ) + for tid, val in [ + ("string", "2000-01-01"), + ("int", 123_456_789), + ("int64", Int64(123_456_789)), + ("double", 1.5), + ("decimal128", Decimal128("1")), + ("boolean", True), + ("array", [2000, 1, 1]), + ("empty_array", []), + ("single_date_array", [datetime(2021, 6, 15, tzinfo=timezone.utc)]), + ("object", {"year": 2000}), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*")), + ("javascript", Code("function() {}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Amount Type]: a non-numeric amount is rejected. +DATESUBTRACT_AMOUNT_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"amount_{tid}", + expression={ + "$dateSubtract": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "unit": "day", + "amount": val, + } + }, + error_code=DATEADD_INVALID_AMOUNT_ERROR, + msg=f"$dateSubtract should reject a {tid} amount", + ) + for tid, val in [ + ("string", "5"), + ("boolean", True), + ("array", [5]), + ("object", {"value": 5}), + ("datetime", datetime(2000, 1, 1, tzinfo=timezone.utc)), + ("objectid", ObjectId("600000000000000000000000")), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*")), + ("javascript", Code("function() {}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Amount Non-Finite]: a NaN or infinite numeric amount is rejected. +DATESUBTRACT_AMOUNT_NONFINITE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"amount_{tid}", + expression={ + "$dateSubtract": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "unit": "day", + "amount": val, + } + }, + error_code=DATEADD_INVALID_AMOUNT_ERROR, + msg=f"$dateSubtract should reject a {tid} amount", + ) + for tid, val in [ + ("nan", FLOAT_NAN), + ("decimal128_nan", DECIMAL128_NAN), + ("infinity", FLOAT_INFINITY), + ("neg_infinity", FLOAT_NEGATIVE_INFINITY), + ("decimal128_infinity", DECIMAL128_INFINITY), + ("decimal128_neg_infinity", DECIMAL128_NEGATIVE_INFINITY), + ] +] + +# Property [Unit Type]: a non-string unit is rejected as an invalid date unit. +DATESUBTRACT_UNIT_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"unit_{tid}", + expression={ + "$dateSubtract": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "unit": val, + "amount": 5, + } + }, + error_code=INVALID_DATE_UNIT_ERROR, + msg=f"$dateSubtract should reject a {tid} unit", + ) + for tid, val in [ + ("number", 1), + ("int64", Int64(1)), + ("double", 1.0), + ("decimal128", Decimal128("1")), + ("boolean", True), + ("array", ["day"]), + ("object", {"type": "day"}), + ("datetime", datetime(2000, 1, 1, tzinfo=timezone.utc)), + ("objectid", ObjectId("600000000000000000000000")), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex("day")), + ("javascript", Code("function() {}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Unit String]: an unrecognized unit string, including wrong case and plurals, is +# rejected at parse time. +DATESUBTRACT_UNIT_STRING_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"unit_{tid}", + expression={ + "$dateSubtract": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "unit": val, + "amount": 5, + } + }, + error_code=FAILED_TO_PARSE_ERROR, + msg=f"$dateSubtract should reject the {desc}", + ) + for tid, val, desc in [ + ("invalid_string", "invalid", "unrecognized unit string"), + ("empty_string", "", "empty string unit"), + ("mixed_case_Day", "Day", "mixed-case unit Day"), + ("mixed_case_Hour", "Hour", "mixed-case unit Hour"), + ("mixed_case_Month", "Month", "mixed-case unit Month"), + ("mixed_case_Quarter", "Quarter", "mixed-case unit Quarter"), + ("mixed_case_Week", "Week", "mixed-case unit Week"), + ("mixed_case_Second", "Second", "mixed-case unit Second"), + ("mixed_case_Minute", "Minute", "mixed-case unit Minute"), + ("mixed_case_Millisecond", "Millisecond", "mixed-case unit Millisecond"), + ("mixed_case_Year", "Year", "mixed-case unit Year"), + ("uppercase_DAY", "DAY", "uppercase unit DAY"), + ("uppercase_MILLISECOND", "MILLISECOND", "uppercase unit MILLISECOND"), + ("uppercase_YEAR", "YEAR", "uppercase unit YEAR"), + ("uppercase_HOUR", "HOUR", "uppercase unit HOUR"), + ("uppercase_MONTH", "MONTH", "uppercase unit MONTH"), + ("uppercase_QUARTER", "QUARTER", "uppercase unit QUARTER"), + ("uppercase_WEEK", "WEEK", "uppercase unit WEEK"), + ("uppercase_SECOND", "SECOND", "uppercase unit SECOND"), + ("uppercase_MINUTE", "MINUTE", "uppercase unit MINUTE"), + ("plural_years", "years", "plural unit years"), + ("plural_days", "days", "plural unit days"), + ("plural_months", "months", "plural unit months"), + ("plural_hours", "hours", "plural unit hours"), + ("plural_minutes", "minutes", "plural unit minutes"), + ("plural_seconds", "seconds", "plural unit seconds"), + ("plural_milliseconds", "milliseconds", "plural unit milliseconds"), + ("plural_weeks", "weeks", "plural unit weeks"), + ("plural_quarters", "quarters", "plural unit quarters"), + ("epoch_invalid", "epoch", "invalid unit epoch"), + ] +] + +# Property [Invalid Timezone]: an unrecognized or malformed timezone string is rejected. +DATESUBTRACT_TIMEZONE_INVALID_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"timezone_{tid}", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateSubtract": {"startDate": "$date", "unit": "hour", "amount": 5, "timezone": tz} + }, + error_code=INVALID_TIMEZONE_ERROR, + msg=f"$dateSubtract should reject {desc}", + ) + for tid, tz, desc in [ + ("offset_3digit_hours_invalid", "+100:00", "a 3-digit hour offset"), + ("invalid", "Invalid/Timezone", "an unrecognized Olson timezone"), + ("empty_string", "", "an empty string timezone"), + ("olson_wrong_case_lowercase", "america/new_york", "an all-lowercase Olson name"), + ("olson_wrong_case_uppercase", "AMERICA/NEW_YORK", "an all-uppercase Olson name"), + ("olson_wrong_case_mixed", "america/New_York", "a mixed-case Olson name"), + ] +] + +# Property [Timezone Type]: a non-string timezone is rejected as an invalid type. +DATESUBTRACT_TIMEZONE_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"timezone_{tid}", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateSubtract": {"startDate": "$date", "unit": "hour", "amount": 5, "timezone": tz} + }, + error_code=INVALID_TIMEZONE_TYPE_ERROR, + msg=f"$dateSubtract should reject a {tid} timezone", + ) + for tid, tz in [ + ("int", 5), + ("int64", Int64(1)), + ("double", 1.0), + ("decimal128", Decimal128("1")), + ("boolean", True), + ("array", ["UTC"]), + ("object", {"tz": "UTC"}), + ("datetime", datetime(2000, 1, 1, tzinfo=timezone.utc)), + ("objectid", ObjectId("600000000000000000000000")), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*")), + ("javascript", Code("function() {}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Overflow]: an amount that pushes the result beyond the representable date range +# is rejected. +DATESUBTRACT_OVERFLOW_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "large_positive_month_overflow", + doc={"date": datetime(2020, 12, 31, 12, 10, 5, tzinfo=timezone.utc)}, + expression={ + "$dateSubtract": {"startDate": "$date", "unit": "month", "amount": 30_000_000_000} + }, + error_code=DATEADD_INVALID_LARGE_VALUE_ERROR, + msg="$dateSubtract should reject a month amount that underflows the date range", + ), +] + +# Property [Array-Resolving Path]: a startDate field path that resolves to an array is rejected +# by the operator's startDate type contract. +DATESUBTRACT_ARRAY_PATH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "composite_array_path", + doc={ + "a": [ + {"b": datetime(2021, 6, 15, tzinfo=timezone.utc)}, + {"b": datetime(2021, 7, 1, tzinfo=timezone.utc)}, + ] + }, + expression={"$dateSubtract": {"startDate": "$a.b", "unit": "day", "amount": 1}}, + error_code=DATEADD_INVALID_STARTDATE_ERROR, + msg="$dateSubtract should reject a composite array field path as startDate", + ), + ExpressionTestCase( + "single_element_array_path", + doc={"a": [{"b": datetime(2021, 6, 15, tzinfo=timezone.utc)}]}, + expression={"$dateSubtract": {"startDate": "$a.b", "unit": "day", "amount": 1}}, + error_code=DATEADD_INVALID_STARTDATE_ERROR, + msg="$dateSubtract should reject a single-element array field path as startDate", + ), +] + +# Property [Argument Shape]: a missing required field or an unknown field is rejected. +DATESUBTRACT_ARGUMENT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "arg_missing_startDate", + expression={"$dateSubtract": {"unit": "day", "amount": 1}}, + error_code=DATEADD_MISSING_FIELD_ERROR, + msg="$dateSubtract should error when startDate is missing", + ), + ExpressionTestCase( + "arg_missing_unit", + expression={ + "$dateSubtract": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "amount": 1, + } + }, + error_code=DATEADD_MISSING_FIELD_ERROR, + msg="$dateSubtract should error when unit is missing", + ), + ExpressionTestCase( + "arg_missing_amount", + expression={ + "$dateSubtract": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "unit": "day", + } + }, + error_code=DATEADD_MISSING_FIELD_ERROR, + msg="$dateSubtract should error when amount is missing", + ), + ExpressionTestCase( + "arg_empty_object", + expression={"$dateSubtract": {}}, + error_code=DATEADD_MISSING_FIELD_ERROR, + msg="$dateSubtract should error for an empty argument object", + ), + ExpressionTestCase( + "arg_unknown_field", + expression={ + "$dateSubtract": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "unit": "day", + "amount": 1, + "foo": 1, + } + }, + error_code=DATEADD_UNKNOWN_FIELD_ERROR, + msg="$dateSubtract should error for an unknown field", + ), +] + +DATESUBTRACT_ERROR_TESTS: list[ExpressionTestCase] = ( + DATESUBTRACT_AMOUNT_ERROR_TESTS + + DATESUBTRACT_STARTDATE_TYPE_ERROR_TESTS + + DATESUBTRACT_AMOUNT_TYPE_ERROR_TESTS + + DATESUBTRACT_AMOUNT_NONFINITE_ERROR_TESTS + + DATESUBTRACT_UNIT_TYPE_ERROR_TESTS + + DATESUBTRACT_UNIT_STRING_ERROR_TESTS + + DATESUBTRACT_TIMEZONE_INVALID_TESTS + + DATESUBTRACT_TIMEZONE_TYPE_ERROR_TESTS + + DATESUBTRACT_OVERFLOW_ERROR_TESTS + + DATESUBTRACT_ARRAY_PATH_TESTS + + DATESUBTRACT_ARGUMENT_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATESUBTRACT_ERROR_TESTS)) +def test_dateSubtract_errors(collection, test_case: ExpressionTestCase): + """Test $dateSubtract rejects invalid operands, timezones, overflow, and argument shapes.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_expressions.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_expressions.py new file mode 100644 index 000000000..dba41131a --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_expressions.py @@ -0,0 +1,91 @@ +"""$dateSubtract operand evaluation: literal operands and field-reference resolution.""" + +from datetime import datetime, timezone + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Literal Input]: $dateSubtract evaluates literal operands. +DATESUBTRACT_LITERAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "literal_year_subtract", + expression={ + "$dateSubtract": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "unit": "year", + "amount": 1, + } + }, + expected=datetime(1999, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should subtract 1 year from literal operands", + ), + ExpressionTestCase( + "literal_day_subtract", + expression={ + "$dateSubtract": { + "startDate": datetime(2000, 1, 11, 12, 0, 0, tzinfo=timezone.utc), + "unit": "day", + "amount": 10, + } + }, + expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should subtract 10 days from literal operands", + ), + ExpressionTestCase( + "literal_null_startDate", + expression={"$dateSubtract": {"startDate": None, "unit": "day", "amount": 1}}, + expected=None, + msg="$dateSubtract should return null for a literal null startDate", + ), +] + +# Property [Field Reference Operands]: the amount and unit operands resolve from field references. +DATESUBTRACT_FIELD_REF_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "field_ref_amount", + doc={"amt": 5}, + expression={ + "$dateSubtract": { + "startDate": datetime(2000, 1, 6, 12, 0, 0, tzinfo=timezone.utc), + "unit": "day", + "amount": "$amt", + } + }, + expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should resolve the amount from a field reference", + ), + ExpressionTestCase( + "field_ref_unit", + doc={"unit_field": "day"}, + expression={ + "$dateSubtract": { + "startDate": datetime(2000, 1, 6, 12, 0, 0, tzinfo=timezone.utc), + "unit": "$unit_field", + "amount": 5, + } + }, + expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should resolve the unit from a field reference", + ), +] + +DATESUBTRACT_EXPRESSION_TESTS: list[ExpressionTestCase] = ( + DATESUBTRACT_LITERAL_TESTS + DATESUBTRACT_FIELD_REF_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATESUBTRACT_EXPRESSION_TESTS)) +def test_dateSubtract_expressions(collection, test_case: ExpressionTestCase): + """Test $dateSubtract evaluates literal and field-reference operands.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_input_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_input_types.py new file mode 100644 index 000000000..a8a6bca7e --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_input_types.py @@ -0,0 +1,161 @@ +"""$dateSubtract date-like input types: Timestamp and ObjectId start dates always return a Date.""" + +from datetime import datetime, timezone + +import pytest +from bson import ObjectId, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.date_utils import ( + oid_from_args, + ts_from_args, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DATE_MS_EPOCH, + OID_EPOCH, + OID_MAX_SIGNED32, + OID_MIN_SIGNED32, + TS_EPOCH, + TS_MAX_SIGNED32, + TS_MAX_UNSIGNED32, +) + +# Property [Timestamp Start Date]: a Timestamp or DatetimeMS start date is accepted and returns +# a Date. +DATESUBTRACT_TIMESTAMP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "timestamp_startDate_day", + doc={"date": ts_from_args(2021, 1, 1, 12, 10, 5)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=datetime(2020, 12, 31, 12, 10, 5, tzinfo=timezone.utc), + msg="$dateSubtract should accept a Timestamp start date and return a Date", + ), + ExpressionTestCase( + "timestamp_startDate_second", + doc={"date": ts_from_args(2000, 1, 1, 12, 0, 1)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "second", "amount": 1}}, + expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should subtract a second from a Timestamp start date", + ), + ExpressionTestCase( + "timestamp_epoch", + doc={"date": TS_EPOCH}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "second", "amount": -1}}, + expected=datetime(1970, 1, 1, 0, 0, 1, tzinfo=timezone.utc), + msg="$dateSubtract should accept an epoch Timestamp start date", + ), + ExpressionTestCase( + "date_ms_epoch_sub_day", + doc={"date": DATE_MS_EPOCH}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=datetime(1969, 12, 31, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should subtract a day from an epoch DatetimeMS start date", + ), + ExpressionTestCase( + "ts_max_s32_sub_second", + doc={"date": TS_MAX_SIGNED32}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "second", "amount": 1}}, + expected=datetime(2038, 1, 19, 3, 14, 6, tzinfo=timezone.utc), + msg="$dateSubtract should subtract a second from a max signed 32-bit Timestamp", + ), + ExpressionTestCase( + "ts_max_u32_sub_second", + doc={"date": TS_MAX_UNSIGNED32}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "second", "amount": 1}}, + expected=datetime(2106, 2, 7, 6, 28, 14, tzinfo=timezone.utc), + msg="$dateSubtract should subtract a second from a max unsigned 32-bit Timestamp", + ), +] + +# Property [ObjectId Start Date]: an ObjectId start date uses its embedded timestamp and +# returns a Date. +DATESUBTRACT_OBJECTID_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "objectid_startDate_year", + doc={"date": oid_from_args(2023, 7, 15, 22, 32, 25)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "year", "amount": 1}}, + expected=datetime(2022, 7, 15, 22, 32, 25, tzinfo=timezone.utc), + msg="$dateSubtract should accept an ObjectId start date and return a Date", + ), + ExpressionTestCase( + "objectid_startDate_second", + doc={"date": oid_from_args(2022, 7, 15, 22, 32, 26)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "second", "amount": 1}}, + expected=datetime(2022, 7, 15, 22, 32, 25, tzinfo=timezone.utc), + msg="$dateSubtract should subtract a second from an ObjectId start date", + ), + ExpressionTestCase( + "objectid_startDate_millisecond", + doc={"date": oid_from_args(2022, 7, 15, 22, 32, 26)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "millisecond", "amount": 500}}, + expected=datetime(2022, 7, 15, 22, 32, 25, 500000, tzinfo=timezone.utc), + msg="$dateSubtract should subtract sub-second precision from an ObjectId start date", + ), + ExpressionTestCase( + "objectid_epoch", + doc={"date": OID_EPOCH}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": -1}}, + expected=datetime(1970, 1, 2, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should accept an epoch ObjectId start date", + ), + ExpressionTestCase( + "objectid_max_signed32", + doc={"date": OID_MAX_SIGNED32}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "second", "amount": 1}}, + expected=datetime(2038, 1, 19, 3, 14, 6, tzinfo=timezone.utc), + msg="$dateSubtract should subtract a second from a max signed 32-bit ObjectId", + ), + ExpressionTestCase( + "objectid_high_bit_pre_epoch", + doc={"date": OID_MIN_SIGNED32}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=datetime(1901, 12, 12, 20, 45, 52, tzinfo=timezone.utc), + msg="$dateSubtract should handle an ObjectId with the high timestamp bit set", + ), +] + +# Property [Return Type]: $dateSubtract returns a Date regardless of the start date's date-like +# type. +DATESUBTRACT_RETURN_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "return_type_from_date", + doc={"date": datetime(2021, 1, 1, tzinfo=timezone.utc)}, + expression={"$type": {"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 1}}}, + expected="date", + msg="$dateSubtract should return a Date from a Date start date", + ), + ExpressionTestCase( + "return_type_from_timestamp", + doc={"date": Timestamp(1609459200, 1)}, + expression={"$type": {"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 1}}}, + expected="date", + msg="$dateSubtract should return a Date from a Timestamp start date", + ), + ExpressionTestCase( + "return_type_from_objectid", + doc={"date": ObjectId("600000000000000000000000")}, + expression={"$type": {"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 1}}}, + expected="date", + msg="$dateSubtract should return a Date from an ObjectId start date", + ), +] + +DATESUBTRACT_INPUT_TYPE_TESTS: list[ExpressionTestCase] = ( + DATESUBTRACT_TIMESTAMP_TESTS + DATESUBTRACT_OBJECTID_TESTS + DATESUBTRACT_RETURN_TYPE_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATESUBTRACT_INPUT_TYPE_TESTS)) +def test_dateSubtract_input_types(collection, test_case: ExpressionTestCase): + """Test $dateSubtract accepts date-like input types and always returns a Date.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_null.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_null.py new file mode 100644 index 000000000..4022f7ab4 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_null.py @@ -0,0 +1,140 @@ +"""$dateSubtract null and missing propagation: a null or missing operand returns null.""" + +from datetime import datetime, timezone + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + MISSING, +) + +# Property [Null Handling]: a null literal for startDate, amount, or unit returns null. +DATESUBTRACT_NULL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_startDate", + doc={"date": None}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=None, + msg="$dateSubtract should return null for a null startDate", + ), + ExpressionTestCase( + "null_amount", + expression={ + "$dateSubtract": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "unit": "day", + "amount": None, + } + }, + expected=None, + msg="$dateSubtract should return null for a null amount", + ), + ExpressionTestCase( + "null_unit", + expression={ + "$dateSubtract": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "unit": None, + "amount": 1, + } + }, + expected=None, + msg="$dateSubtract should return null for a null unit", + ), + ExpressionTestCase( + "null_startDate_zero_amount", + doc={"date": None}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 0}}, + expected=None, + msg="$dateSubtract should return null for a null startDate even with a zero amount", + ), + ExpressionTestCase( + "all_null", + expression={"$dateSubtract": {"startDate": None, "unit": None, "amount": None}}, + expected=None, + msg="$dateSubtract should return null when all inputs are null", + ), +] + +# Property [Missing Field Reference]: a missing startDate, amount, unit, or timezone field +# reference returns null. +DATESUBTRACT_MISSING_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "missing_startDate", + expression={"$dateSubtract": {"startDate": MISSING, "unit": "day", "amount": 1}}, + expected=None, + msg="$dateSubtract should return null for a missing startDate field reference", + ), + ExpressionTestCase( + "missing_amount", + expression={ + "$dateSubtract": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "unit": "day", + "amount": MISSING, + } + }, + expected=None, + msg="$dateSubtract should return null for a missing amount field reference", + ), + ExpressionTestCase( + "missing_unit", + expression={ + "$dateSubtract": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "unit": MISSING, + "amount": 1, + } + }, + expected=None, + msg="$dateSubtract should return null for a missing unit field reference", + ), + ExpressionTestCase( + "missing_startDate_zero_amount", + expression={"$dateSubtract": {"startDate": MISSING, "unit": "day", "amount": 0}}, + expected=None, + msg="$dateSubtract should return null for a missing startDate reference and zero amount", + ), + ExpressionTestCase( + "missing_timezone", + expression={ + "$dateSubtract": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "unit": "day", + "amount": 1, + "timezone": MISSING, + } + }, + expected=None, + msg="$dateSubtract should return null for a missing timezone field reference", + ), + ExpressionTestCase( + "missing_startDate_with_tz", + expression={ + "$dateSubtract": {"startDate": MISSING, "unit": "day", "amount": 1, "timezone": "UTC"} + }, + expected=None, + msg="$dateSubtract should return null for a missing startDate even with a timezone", + ), +] + +DATESUBTRACT_NULL_MISSING_TESTS: list[ExpressionTestCase] = ( + DATESUBTRACT_NULL_TESTS + DATESUBTRACT_MISSING_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATESUBTRACT_NULL_MISSING_TESTS)) +def test_dateSubtract_null(collection, test_case: ExpressionTestCase): + """Test $dateSubtract returns null for null literals and missing field references.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_timezone.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_timezone.py new file mode 100644 index 000000000..cb96e147b --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_timezone.py @@ -0,0 +1,318 @@ +"""$dateSubtract timezone handling: Olson ids, UTC offsets, DST, and field references.""" + +from datetime import datetime, timezone + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Olson Timezone]: a valid Olson timezone id is accepted. +DATESUBTRACT_TIMEZONE_OLSON_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"timezone_{tid}", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateSubtract": {"startDate": "$date", "unit": "hour", "amount": 5, "timezone": tz} + }, + expected=datetime(2000, 1, 1, 7, 0, 0, tzinfo=timezone.utc), + msg=f"$dateSubtract should accept the {tz} timezone", + ) + for tid, tz in [ + ("utc", "UTC"), + ("gmt", "GMT"), + ("america_ny", "America/New_York"), + ("europe_london", "Europe/London"), + ("asia_tokyo", "Asia/Tokyo"), + ("asia_kolkata", "Asia/Kolkata"), + ("pacific_apia", "Pacific/Apia"), + ] +] + +# Property [UTC Offset]: syntactically valid UTC offset strings are accepted, including +# numerically out-of-range but well-formed offsets. +DATESUBTRACT_TIMEZONE_OFFSET_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"timezone_offset_{tid}", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateSubtract": {"startDate": "$date", "unit": "hour", "amount": 5, "timezone": tz} + }, + expected=datetime(2000, 1, 1, 7, 0, 0, tzinfo=timezone.utc), + msg=f"$dateSubtract should accept the {tz} UTC offset", + ) + for tid, tz in [ + ("colon_positive", "+05:30"), + ("no_colon", "-0530"), + ("hour_only", "+03"), + ("zero", "+00:00"), + ("max_east", "+14:00"), + ("max_west", "-11:00"), + ("half_hour_west", "-02:30"), + ("45min", "+05:45"), + ("over60_minutes_positive", "+05:70"), + ("over60_minutes_negative", "-05:70"), + ("over24_hours_positive", "+25:00"), + ("over24_hours_negative", "-25:00"), + ("max_valid_positive", "+99:99"), + ("max_valid_negative", "-99:99"), + ("out_of_range_east", "+15:00"), + ("out_of_range_west", "-13:00"), + ] +] + +# Property [DST Transition]: day and larger units track wall-clock time across DST, while +# 24-hour and sub-day units subtract absolute time and do not adjust. +DATESUBTRACT_TIMEZONE_DST_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "dst_spring_forward_day", + doc={"date": datetime(2021, 3, 14, 14, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateSubtract": { + "startDate": "$date", + "unit": "day", + "amount": 1, + "timezone": "America/New_York", + } + }, + expected=datetime(2021, 3, 13, 15, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should DST-adjust a day across spring-forward", + ), + ExpressionTestCase( + "dst_spring_forward_hour_24", + doc={"date": datetime(2021, 3, 14, 15, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateSubtract": { + "startDate": "$date", + "unit": "hour", + "amount": 24, + "timezone": "America/New_York", + } + }, + expected=datetime(2021, 3, 13, 15, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should not DST-adjust 24 hours across spring-forward", + ), + ExpressionTestCase( + "dst_spring_forward_week", + doc={"date": datetime(2021, 3, 14, 9, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateSubtract": { + "startDate": "$date", + "unit": "week", + "amount": 1, + "timezone": "America/New_York", + } + }, + expected=datetime(2021, 3, 7, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should DST-adjust a week across spring-forward", + ), + ExpressionTestCase( + "dst_spring_forward_month", + doc={"date": datetime(2021, 3, 14, 9, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateSubtract": { + "startDate": "$date", + "unit": "month", + "amount": 1, + "timezone": "America/New_York", + } + }, + expected=datetime(2021, 2, 14, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should DST-adjust a month across spring-forward", + ), + ExpressionTestCase( + "dst_spring_forward_quarter", + doc={"date": datetime(2021, 4, 14, 9, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateSubtract": { + "startDate": "$date", + "unit": "quarter", + "amount": 1, + "timezone": "America/New_York", + } + }, + expected=datetime(2021, 1, 14, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should DST-adjust a quarter across spring-forward", + ), + ExpressionTestCase( + "dst_fall_back_day", + doc={"date": datetime(2021, 11, 7, 11, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateSubtract": { + "startDate": "$date", + "unit": "day", + "amount": 1, + "timezone": "America/New_York", + } + }, + expected=datetime(2021, 11, 6, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should DST-adjust a day across fall-back", + ), + ExpressionTestCase( + "dst_fall_back_hour_24", + doc={"date": datetime(2021, 11, 7, 10, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateSubtract": { + "startDate": "$date", + "unit": "hour", + "amount": 24, + "timezone": "America/New_York", + } + }, + expected=datetime(2021, 11, 6, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should not DST-adjust 24 hours across fall-back", + ), + ExpressionTestCase( + "dst_fall_back_week", + doc={"date": datetime(2021, 11, 7, 11, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateSubtract": { + "startDate": "$date", + "unit": "week", + "amount": 1, + "timezone": "America/New_York", + } + }, + expected=datetime(2021, 10, 31, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should DST-adjust a week across fall-back", + ), + ExpressionTestCase( + "dst_spring_minute_no_adjust", + doc={"date": datetime(2021, 3, 14, 7, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateSubtract": { + "startDate": "$date", + "unit": "minute", + "amount": 1, + "timezone": "America/New_York", + } + }, + expected=datetime(2021, 3, 14, 6, 59, 0, tzinfo=timezone.utc), + msg="$dateSubtract should not DST-adjust a minute", + ), + ExpressionTestCase( + "dst_spring_second_no_adjust", + doc={"date": datetime(2021, 3, 14, 7, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateSubtract": { + "startDate": "$date", + "unit": "second", + "amount": 1, + "timezone": "America/New_York", + } + }, + expected=datetime(2021, 3, 14, 6, 59, 59, tzinfo=timezone.utc), + msg="$dateSubtract should not DST-adjust a second", + ), + ExpressionTestCase( + "dst_spring_millisecond_no_adjust", + doc={"date": datetime(2021, 3, 14, 7, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateSubtract": { + "startDate": "$date", + "unit": "millisecond", + "amount": 1, + "timezone": "America/New_York", + } + }, + expected=datetime(2021, 3, 14, 6, 59, 59, 999000, tzinfo=timezone.utc), + msg="$dateSubtract should not DST-adjust a millisecond", + ), + ExpressionTestCase( + "dst_europe_paris_hour_no_adjust", + doc={"date": datetime(2020, 10, 25, 18, 10, 0, tzinfo=timezone.utc)}, + expression={ + "$dateSubtract": { + "startDate": "$date", + "unit": "hour", + "amount": 24, + "timezone": "Europe/Paris", + } + }, + expected=datetime(2020, 10, 24, 18, 10, 0, tzinfo=timezone.utc), + msg="$dateSubtract should not DST-adjust 24 hours in Europe/Paris", + ), + ExpressionTestCase( + "dst_europe_paris_day_adjust", + doc={"date": datetime(2020, 10, 25, 19, 10, 0, tzinfo=timezone.utc)}, + expression={ + "$dateSubtract": { + "startDate": "$date", + "unit": "day", + "amount": 1, + "timezone": "Europe/Paris", + } + }, + expected=datetime(2020, 10, 24, 18, 10, 0, tzinfo=timezone.utc), + msg="$dateSubtract should DST-adjust a day across Europe/Paris fall-back", + ), +] + +# Property [Timezone Field Reference]: the timezone operand resolves from a field, and a +# missing timezone field reference returns null. +DATESUBTRACT_TIMEZONE_FIELD_REF_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "timezone_field_ref", + doc={"tz": "Europe/Paris"}, + expression={ + "$dateSubtract": { + "startDate": datetime(2021, 2, 28, 12, 10, 5, tzinfo=timezone.utc), + "unit": "month", + "amount": 2, + "timezone": "$tz", + } + }, + expected=datetime(2020, 12, 28, 12, 10, 5, tzinfo=timezone.utc), + msg="$dateSubtract should resolve the timezone from a field reference", + ), + ExpressionTestCase( + "timezone_missing_field_ref", + doc={}, + expression={ + "$dateSubtract": { + "startDate": datetime(2021, 2, 28, 12, 10, 5, tzinfo=timezone.utc), + "unit": "month", + "amount": 2, + "timezone": "$tz", + } + }, + expected=None, + msg="$dateSubtract should return null for a missing timezone field reference", + ), +] + +# Property [Null Timezone]: a null timezone returns null. +DATESUBTRACT_TIMEZONE_NULL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "timezone_null", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateSubtract": {"startDate": "$date", "unit": "hour", "amount": 5, "timezone": None} + }, + expected=None, + msg="$dateSubtract should return null when the timezone is null", + ), +] + +DATESUBTRACT_TIMEZONE_TESTS: list[ExpressionTestCase] = ( + DATESUBTRACT_TIMEZONE_OLSON_TESTS + + DATESUBTRACT_TIMEZONE_OFFSET_TESTS + + DATESUBTRACT_TIMEZONE_DST_TESTS + + DATESUBTRACT_TIMEZONE_FIELD_REF_TESTS + + DATESUBTRACT_TIMEZONE_NULL_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATESUBTRACT_TIMEZONE_TESTS)) +def test_dateSubtract_timezone(collection, test_case: ExpressionTestCase): + """Test $dateSubtract applies the timezone operand for calendar-aware units.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_arguments.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_arguments.py new file mode 100644 index 000000000..699ffdcb8 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_arguments.py @@ -0,0 +1,102 @@ +"""$dateTrunc optional-argument acceptance: required plus optional fields and binSize types.""" + +from datetime import datetime, timezone + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Argument Handling]: $dateTrunc accepts the required date and unit plus the optional +# binSize, timezone, and startOfWeek parameters. +DATETRUNC_ARG_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "arg_required_only", + doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "hour"}}, + expected=datetime(2021, 3, 20, 11, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate with the required fields only", + ), + ExpressionTestCase( + "arg_with_binSize", + doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "hour", "binSize": 2}}, + expected=datetime(2021, 3, 20, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept a binSize", + ), + ExpressionTestCase( + "arg_with_timezone", + doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day", "timezone": "UTC"}}, + expected=datetime(2021, 3, 20, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept a timezone", + ), + ExpressionTestCase( + "arg_with_startOfWeek", + doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "monday"}}, + expected=datetime(2021, 3, 15, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept a startOfWeek", + ), + ExpressionTestCase( + "arg_all_fields", + doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, + expression={ + "$dateTrunc": { + "date": "$date", + "unit": "week", + "binSize": 1, + "timezone": "UTC", + "startOfWeek": "monday", + } + }, + expected=datetime(2021, 3, 15, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept all fields together", + ), +] + +# Property [BinSize Numeric Types]: integral int64, decimal128, and double binSize values are +# accepted. +DATETRUNC_BINSIZE_ACCEPT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "binSize_int64", + doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "hour", "binSize": Int64(2)}}, + expected=datetime(2021, 3, 20, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept an int64 binSize", + ), + ExpressionTestCase( + "binSize_decimal128", + doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "hour", "binSize": Decimal128("2")}}, + expected=datetime(2021, 3, 20, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept a decimal128 binSize", + ), + ExpressionTestCase( + "binSize_double_integral", + doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "hour", "binSize": 2.0}}, + expected=datetime(2021, 3, 20, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept an integral double binSize", + ), +] + +DATETRUNC_ARGUMENT_TESTS: list[ExpressionTestCase] = ( + DATETRUNC_ARG_TESTS + DATETRUNC_BINSIZE_ACCEPT_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATETRUNC_ARGUMENT_TESTS)) +def test_dateTrunc_arguments(collection, test_case: ExpressionTestCase): + """Test $dateTrunc accepts its optional arguments and numeric binSize types.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_binsize_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_binsize_errors.py new file mode 100644 index 000000000..9a6c09531 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_binsize_errors.py @@ -0,0 +1,207 @@ +"""$dateTrunc binSize rejection cases: non-numeric, non-integral, non-positive, and overflow.""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + DATETRUNC_BINSIZE_OVERFLOW_HOUR_ERROR, + DATETRUNC_BINSIZE_OVERFLOW_YEAR_ERROR, + DATETRUNC_INVALID_BINSIZE_ERROR, + DATETRUNC_INVALID_BINSIZE_VALUE_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_HALF, + DECIMAL128_INFINITY, + DECIMAL128_NAN, + FLOAT_INFINITY, + FLOAT_NAN, +) + +# Property [BinSize Type]: a non-numeric binSize is rejected. +DATETRUNC_BINSIZE_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"binSize_{tid}", + expression={ + "$dateTrunc": { + "date": datetime(2021, 1, 1, tzinfo=timezone.utc), + "unit": "day", + "binSize": val, + } + }, + error_code=DATETRUNC_INVALID_BINSIZE_ERROR, + msg=f"$dateTrunc should reject a {tid} binSize", + ) + for tid, val in [ + ("string", "2"), + ("boolean", True), + ("array", [1]), + ("empty_array", []), + ("object", {"a": 1}), + ("datetime", datetime(2021, 1, 1, tzinfo=timezone.utc)), + ("objectid", ObjectId("600000000000000000000000")), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*")), + ("javascript", Code("function() {}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [BinSize Non-Integral]: a fractional or non-finite numeric binSize is rejected. +DATETRUNC_BINSIZE_NONINTEGRAL_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "binSize_fractional", + expression={ + "$dateTrunc": { + "date": datetime(2021, 1, 1, tzinfo=timezone.utc), + "unit": "day", + "binSize": 0.5, + } + }, + error_code=DATETRUNC_INVALID_BINSIZE_ERROR, + msg="$dateTrunc should reject a fractional binSize", + ), + ExpressionTestCase( + "binSize_nan", + expression={ + "$dateTrunc": { + "date": datetime(2021, 1, 1, tzinfo=timezone.utc), + "unit": "day", + "binSize": FLOAT_NAN, + } + }, + error_code=DATETRUNC_INVALID_BINSIZE_ERROR, + msg="$dateTrunc should reject a NaN binSize", + ), + ExpressionTestCase( + "binSize_infinity", + expression={ + "$dateTrunc": { + "date": datetime(2021, 1, 1, tzinfo=timezone.utc), + "unit": "day", + "binSize": FLOAT_INFINITY, + } + }, + error_code=DATETRUNC_INVALID_BINSIZE_ERROR, + msg="$dateTrunc should reject an infinite binSize", + ), + ExpressionTestCase( + "binSize_decimal128_fractional", + expression={ + "$dateTrunc": { + "date": datetime(2021, 1, 1, tzinfo=timezone.utc), + "unit": "day", + "binSize": DECIMAL128_HALF, + } + }, + error_code=DATETRUNC_INVALID_BINSIZE_ERROR, + msg="$dateTrunc should reject a fractional decimal128 binSize", + ), + ExpressionTestCase( + "binSize_decimal128_nan", + expression={ + "$dateTrunc": { + "date": datetime(2021, 1, 1, tzinfo=timezone.utc), + "unit": "day", + "binSize": DECIMAL128_NAN, + } + }, + error_code=DATETRUNC_INVALID_BINSIZE_ERROR, + msg="$dateTrunc should reject a NaN decimal128 binSize", + ), + ExpressionTestCase( + "binSize_decimal128_infinity", + expression={ + "$dateTrunc": { + "date": datetime(2021, 1, 1, tzinfo=timezone.utc), + "unit": "day", + "binSize": DECIMAL128_INFINITY, + } + }, + error_code=DATETRUNC_INVALID_BINSIZE_ERROR, + msg="$dateTrunc should reject an infinite decimal128 binSize", + ), +] + +# Property [BinSize Value]: a zero or negative binSize is rejected as an invalid value. +DATETRUNC_BINSIZE_VALUE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "binSize_zero", + expression={ + "$dateTrunc": { + "date": datetime(2021, 1, 1, tzinfo=timezone.utc), + "unit": "day", + "binSize": 0, + } + }, + error_code=DATETRUNC_INVALID_BINSIZE_VALUE_ERROR, + msg="$dateTrunc should reject a zero binSize", + ), + ExpressionTestCase( + "binSize_negative", + expression={ + "$dateTrunc": { + "date": datetime(2021, 1, 1, tzinfo=timezone.utc), + "unit": "day", + "binSize": -1, + } + }, + error_code=DATETRUNC_INVALID_BINSIZE_VALUE_ERROR, + msg="$dateTrunc should reject a negative binSize", + ), +] + +# Property [BinSize Overflow]: a binSize that overflows the date range for its unit is rejected. +DATETRUNC_BINSIZE_OVERFLOW_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "binSize_overflow_year", + expression={ + "$dateTrunc": { + "date": datetime(2021, 1, 1, tzinfo=timezone.utc), + "unit": "year", + "binSize": Int64(100_000_000_001), + } + }, + error_code=DATETRUNC_BINSIZE_OVERFLOW_YEAR_ERROR, + msg="$dateTrunc should reject a binSize that overflows the year unit", + ), + ExpressionTestCase( + "binSize_overflow_hour", + expression={ + "$dateTrunc": { + "date": datetime(2021, 1, 1, tzinfo=timezone.utc), + "unit": "hour", + "binSize": Int64(2_562_047_788_016_999), + } + }, + error_code=DATETRUNC_BINSIZE_OVERFLOW_HOUR_ERROR, + msg="$dateTrunc should reject a binSize that overflows the hour unit", + ), +] + +DATETRUNC_BINSIZE_ERROR_TESTS: list[ExpressionTestCase] = ( + DATETRUNC_BINSIZE_TYPE_ERROR_TESTS + + DATETRUNC_BINSIZE_NONINTEGRAL_ERROR_TESTS + + DATETRUNC_BINSIZE_VALUE_ERROR_TESTS + + DATETRUNC_BINSIZE_OVERFLOW_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATETRUNC_BINSIZE_ERROR_TESTS)) +def test_dateTrunc_binsize_errors(collection, test_case: ExpressionTestCase): + """Test $dateTrunc rejects invalid binSize types, values, and overflow.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_errors.py new file mode 100644 index 000000000..fc9aa6e7e --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_errors.py @@ -0,0 +1,377 @@ +"""$dateTrunc rejection cases: invalid date, unit, timezone, startOfWeek, and argument shape.""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + DATETRUNC_MISSING_DATE_ERROR, + DATETRUNC_MISSING_UNIT_ERROR, + DATETRUNC_NON_OBJECT_ERROR, + DATETRUNC_UNKNOWN_FIELD_ERROR, + FAILED_TO_PARSE_ERROR, + INVALID_DATE_UNIT_ERROR, + INVALID_DATE_VALUE_ERROR, + INVALID_STARTOFWEEK_ERROR, + INVALID_STARTOFWEEK_TYPE_ERROR, + INVALID_TIMEZONE_ERROR, + INVALID_TIMEZONE_TYPE_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NEGATIVE_INFINITY, +) + +# Property [Argument Shape]: a missing required field, an empty object, an unknown field, or a +# non-object argument is rejected. +DATETRUNC_ARG_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "arg_missing_date", + expression={"$dateTrunc": {"unit": "day"}}, + error_code=DATETRUNC_MISSING_DATE_ERROR, + msg="$dateTrunc should error when date is missing", + ), + ExpressionTestCase( + "arg_missing_unit", + expression={"$dateTrunc": {"date": datetime(2021, 1, 1, tzinfo=timezone.utc)}}, + error_code=DATETRUNC_MISSING_UNIT_ERROR, + msg="$dateTrunc should error when unit is missing", + ), + ExpressionTestCase( + "arg_empty_object", + expression={"$dateTrunc": {}}, + error_code=DATETRUNC_MISSING_DATE_ERROR, + msg="$dateTrunc should error for an empty argument object", + ), + ExpressionTestCase( + "arg_unknown_field", + expression={ + "$dateTrunc": { + "date": datetime(2021, 1, 1, tzinfo=timezone.utc), + "unit": "day", + "foo": 1, + } + }, + error_code=DATETRUNC_UNKNOWN_FIELD_ERROR, + msg="$dateTrunc should error for an unknown field", + ), + ExpressionTestCase( + "arg_non_object_string", + expression={"$dateTrunc": "string"}, + error_code=DATETRUNC_NON_OBJECT_ERROR, + msg="$dateTrunc should reject a string argument", + ), + ExpressionTestCase( + "arg_non_object_array", + expression={"$dateTrunc": [1, 2]}, + error_code=DATETRUNC_NON_OBJECT_ERROR, + msg="$dateTrunc should reject an array argument", + ), + ExpressionTestCase( + "arg_non_object_number", + expression={"$dateTrunc": 123}, + error_code=DATETRUNC_NON_OBJECT_ERROR, + msg="$dateTrunc should reject a numeric argument", + ), +] + +# Property [Date Type]: a non-date, non-ObjectId, non-Timestamp date value is rejected. +DATETRUNC_DATE_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + *[ + ExpressionTestCase( + f"date_{tid}", + doc={"date": val}, + expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, + error_code=INVALID_DATE_VALUE_ERROR, + msg=f"$dateTrunc should reject a {tid} date", + ) + for tid, val in [ + ("string", "2021"), + ("int", 123), + ("int64", Int64(123)), + ("double", 1.0), + ("decimal128", Decimal128("1")), + ("boolean", True), + ("array", [1, 2]), + ("empty_array", []), + ("object", {"a": 1}), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*")), + ("javascript", Code("function() {}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] + ], + ExpressionTestCase( + "date_boolean_false", + doc={"date": False}, + expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, + error_code=INVALID_DATE_VALUE_ERROR, + msg="$dateTrunc should reject a false boolean date", + ), + ExpressionTestCase( + "date_single_date_array", + doc={"date": [datetime(2021, 6, 15, tzinfo=timezone.utc)]}, + expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, + error_code=INVALID_DATE_VALUE_ERROR, + msg="$dateTrunc should reject a single-element array containing a date", + ), + ExpressionTestCase( + "date_decimal128_infinity", + doc={"date": DECIMAL128_INFINITY}, + expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, + error_code=INVALID_DATE_VALUE_ERROR, + msg="$dateTrunc should reject a Decimal128 Infinity date", + ), + ExpressionTestCase( + "date_decimal128_neg_infinity", + doc={"date": DECIMAL128_NEGATIVE_INFINITY}, + expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, + error_code=INVALID_DATE_VALUE_ERROR, + msg="$dateTrunc should reject a Decimal128 -Infinity date", + ), +] + +# Property [Unit Type]: a non-string unit is rejected as an invalid date unit. +DATETRUNC_UNIT_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"unit_{tid}", + expression={"$dateTrunc": {"date": datetime(2021, 1, 1, tzinfo=timezone.utc), "unit": val}}, + error_code=INVALID_DATE_UNIT_ERROR, + msg=f"$dateTrunc should reject a {tid} unit", + ) + for tid, val in [ + ("int", 1), + ("int64", Int64(1)), + ("double", 1.0), + ("decimal128", Decimal128("1")), + ("boolean", True), + ("array", ["day"]), + ("empty_array", []), + ("object", {"t": "day"}), + ("datetime", datetime(2021, 1, 1, tzinfo=timezone.utc)), + ("objectid", ObjectId("600000000000000000000000")), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex("day")), + ("javascript", Code("function() {}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Unit String]: an unrecognized unit string, including wrong case and plurals, is +# rejected at parse time. +DATETRUNC_UNIT_STRING_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"unit_{tid}", + expression={"$dateTrunc": {"date": datetime(2021, 1, 1, tzinfo=timezone.utc), "unit": val}}, + error_code=FAILED_TO_PARSE_ERROR, + msg=f"$dateTrunc should reject the {desc}", + ) + for tid, val, desc in [ + ("invalid_string", "invalid", "unrecognized unit string"), + ("empty_string", "", "empty string unit"), + ("mixed_case_Year", "Year", "mixed-case unit Year"), + ("mixed_case_Day", "Day", "mixed-case unit Day"), + ("mixed_case_Hour", "Hour", "mixed-case unit Hour"), + ("mixed_case_Month", "Month", "mixed-case unit Month"), + ("mixed_case_Quarter", "Quarter", "mixed-case unit Quarter"), + ("mixed_case_Week", "Week", "mixed-case unit Week"), + ("mixed_case_Second", "Second", "mixed-case unit Second"), + ("mixed_case_Minute", "Minute", "mixed-case unit Minute"), + ("mixed_case_Millisecond", "Millisecond", "mixed-case unit Millisecond"), + ("uppercase_YEAR", "YEAR", "uppercase unit YEAR"), + ("uppercase_DAY", "DAY", "uppercase unit DAY"), + ("uppercase_HOUR", "HOUR", "uppercase unit HOUR"), + ("uppercase_MONTH", "MONTH", "uppercase unit MONTH"), + ("uppercase_MILLISECOND", "MILLISECOND", "uppercase unit MILLISECOND"), + ("uppercase_QUARTER", "QUARTER", "uppercase unit QUARTER"), + ("uppercase_WEEK", "WEEK", "uppercase unit WEEK"), + ("uppercase_SECOND", "SECOND", "uppercase unit SECOND"), + ("uppercase_MINUTE", "MINUTE", "uppercase unit MINUTE"), + ("plural_years", "years", "plural unit years"), + ("plural_months", "months", "plural unit months"), + ("plural_days", "days", "plural unit days"), + ("plural_hours", "hours", "plural unit hours"), + ("plural_minutes", "minutes", "plural unit minutes"), + ("plural_seconds", "seconds", "plural unit seconds"), + ("plural_milliseconds", "milliseconds", "plural unit milliseconds"), + ("plural_weeks", "weeks", "plural unit weeks"), + ("plural_quarters", "quarters", "plural unit quarters"), + ] +] + +# Property [Array Path Rejection]: a field path resolving to an array is rejected as an invalid +# date value. +DATETRUNC_ARRAY_PATH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "composite_array_path", + doc={ + "a": [ + {"b": datetime(2021, 6, 15, tzinfo=timezone.utc)}, + {"b": datetime(2021, 7, 1, tzinfo=timezone.utc)}, + ] + }, + expression={"$dateTrunc": {"date": "$a.b", "unit": "day"}}, + error_code=INVALID_DATE_VALUE_ERROR, + msg="$dateTrunc should reject a composite array field path", + ), + ExpressionTestCase( + "single_element_array_path", + doc={"a": [{"b": datetime(2021, 6, 15, 12, 30, 0, tzinfo=timezone.utc)}]}, + expression={"$dateTrunc": {"date": "$a.b", "unit": "day"}}, + error_code=INVALID_DATE_VALUE_ERROR, + msg="$dateTrunc should reject a single-element array field path", + ), +] + +# Property [Timezone Type]: a non-string timezone is rejected. +DATETRUNC_TIMEZONE_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"tz_{tid}", + expression={ + "$dateTrunc": { + "date": datetime(2021, 6, 15, tzinfo=timezone.utc), + "unit": "day", + "timezone": val, + } + }, + error_code=INVALID_TIMEZONE_TYPE_ERROR, + msg=f"$dateTrunc should reject a {tid} timezone", + ) + for tid, val in [ + ("int", 5), + ("int64", Int64(5)), + ("double", 5.0), + ("decimal128", Decimal128("5")), + ("boolean", True), + ("array", ["UTC"]), + ("empty_array", []), + ("object", {"tz": "UTC"}), + ("datetime", datetime(2021, 1, 1, tzinfo=timezone.utc)), + ("objectid", ObjectId("600000000000000000000000")), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex("UTC")), + ("javascript", Code("function() {}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Timezone String]: an unrecognized timezone string, including wrong-case Olson names and +# out-of-range offsets, is rejected. +DATETRUNC_TIMEZONE_STRING_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"tz_{tid}", + expression={ + "$dateTrunc": { + "date": datetime(2021, 6, 15, tzinfo=timezone.utc), + "unit": "day", + "timezone": val, + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg=f"$dateTrunc should reject the {desc}", + ) + for tid, val, desc in [ + ("invalid_string", "NotATimezone", "unrecognized timezone string"), + ("empty_string", "", "empty timezone string"), + ("olson_lowercase", "america/new_york", "all-lowercase Olson name"), + ("olson_uppercase", "AMERICA/NEW_YORK", "all-uppercase Olson name"), + ("offset_3digit_hours", "+100:00", "three-digit hour offset"), + ] +] + +# Property [StartOfWeek Type]: a non-string startOfWeek is rejected. +DATETRUNC_STARTOFWEEK_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"sow_{tid}", + expression={ + "$dateTrunc": { + "date": datetime(2021, 3, 20, tzinfo=timezone.utc), + "unit": "week", + "startOfWeek": val, + } + }, + error_code=INVALID_STARTOFWEEK_TYPE_ERROR, + msg=f"$dateTrunc should reject a {tid} startOfWeek", + ) + for tid, val in [ + ("int", 1), + ("int64", Int64(1)), + ("double", 1.0), + ("decimal128", Decimal128("1")), + ("boolean", True), + ("array", ["monday"]), + ("empty_array", []), + ("object", {"day": "monday"}), + ("datetime", datetime(2021, 1, 1, tzinfo=timezone.utc)), + ("objectid", ObjectId("600000000000000000000000")), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex("monday")), + ("javascript", Code("function() {}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [StartOfWeek String]: an unrecognized startOfWeek string is rejected. +DATETRUNC_STARTOFWEEK_STRING_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "sow_invalid_string", + expression={ + "$dateTrunc": { + "date": datetime(2021, 3, 20, tzinfo=timezone.utc), + "unit": "week", + "startOfWeek": "notaday", + } + }, + error_code=INVALID_STARTOFWEEK_ERROR, + msg="$dateTrunc should reject an unrecognized startOfWeek string", + ), + ExpressionTestCase( + "sow_empty_string", + expression={ + "$dateTrunc": { + "date": datetime(2021, 3, 20, tzinfo=timezone.utc), + "unit": "week", + "startOfWeek": "", + } + }, + error_code=INVALID_STARTOFWEEK_ERROR, + msg="$dateTrunc should reject an empty startOfWeek string", + ), +] + +DATETRUNC_ERROR_TESTS: list[ExpressionTestCase] = ( + DATETRUNC_ARG_ERROR_TESTS + + DATETRUNC_DATE_TYPE_ERROR_TESTS + + DATETRUNC_UNIT_TYPE_ERROR_TESTS + + DATETRUNC_UNIT_STRING_ERROR_TESTS + + DATETRUNC_ARRAY_PATH_TESTS + + DATETRUNC_TIMEZONE_TYPE_ERROR_TESTS + + DATETRUNC_TIMEZONE_STRING_ERROR_TESTS + + DATETRUNC_STARTOFWEEK_TYPE_ERROR_TESTS + + DATETRUNC_STARTOFWEEK_STRING_ERROR_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATETRUNC_ERROR_TESTS)) +def test_dateTrunc_errors(collection, test_case: ExpressionTestCase): + """Test $dateTrunc rejects invalid date, unit, timezone, startOfWeek, and shapes.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_expressions.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_expressions.py new file mode 100644 index 000000000..3ac3f38d0 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_expressions.py @@ -0,0 +1,111 @@ +"""$dateTrunc operand evaluation: literal arguments and field-reference resolution.""" + +from datetime import datetime, timezone + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.date_utils import ( + oid_from_args, + ts_from_args, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Literal Input]: $dateTrunc evaluates inline literal arguments. +DATETRUNC_LITERAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "literal_basic", + expression={ + "$dateTrunc": { + "date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc), + "unit": "hour", + } + }, + expected=datetime(2021, 3, 20, 11, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate an inline literal date", + ), + ExpressionTestCase( + "literal_null", + expression={"$dateTrunc": {"date": None, "unit": "day"}}, + expected=None, + msg="$dateTrunc should return null for an inline null date literal", + ), +] + +# Property [Field Reference]: $dateTrunc resolves the date from field references, including a +# nested path, an ObjectId, and a Timestamp. +DATETRUNC_FIELD_REF_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "field_ref", + doc={"d": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$d", "unit": "day"}}, + expected=datetime(2021, 3, 20, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should resolve the date from a field reference", + ), + ExpressionTestCase( + "nested_field", + doc={"doc": {"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}}, + expression={"$dateTrunc": {"date": "$doc.date", "unit": "day"}}, + expected=datetime(2021, 3, 20, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should resolve the date from a nested field path", + ), + ExpressionTestCase( + "objectid_field_ref", + doc={"oid": oid_from_args(2021, 3, 20, 11, 30, 5)}, + expression={"$dateTrunc": {"date": "$oid", "unit": "day"}}, + expected=datetime(2021, 3, 20, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should resolve an ObjectId from a field reference", + ), + ExpressionTestCase( + "timestamp_field_ref", + doc={"ts": ts_from_args(2021, 3, 20, 11, 30, 5)}, + expression={"$dateTrunc": {"date": "$ts", "unit": "day"}}, + expected=datetime(2021, 3, 20, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should resolve a Timestamp from a field reference", + ), +] + +# Property [Missing Optional Field Reference]: a missing field reference for an optional parameter +# yields null. +DATETRUNC_MISSING_REF_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "missing_timezone_field_ref", + doc={"d": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$d", "unit": "day", "timezone": "$tz"}}, + expected=None, + msg="$dateTrunc should return null for a missing timezone field reference", + ), + ExpressionTestCase( + "missing_binSize_field_ref", + doc={"d": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$d", "unit": "hour", "binSize": "$bs"}}, + expected=None, + msg="$dateTrunc should return null for a missing binSize field reference", + ), + ExpressionTestCase( + "missing_startOfWeek_field_ref", + doc={"d": datetime(2021, 6, 16, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$d", "unit": "week", "startOfWeek": "$sow"}}, + expected=None, + msg="$dateTrunc should return null for a missing startOfWeek field reference", + ), +] + +DATETRUNC_EXPRESSION_TESTS: list[ExpressionTestCase] = ( + DATETRUNC_LITERAL_TESTS + DATETRUNC_FIELD_REF_TESTS + DATETRUNC_MISSING_REF_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATETRUNC_EXPRESSION_TESTS)) +def test_dateTrunc_expressions(collection, test_case: ExpressionTestCase): + """Test $dateTrunc evaluates literal and field-reference operands.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_input_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_input_types.py new file mode 100644 index 000000000..9f6891794 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_input_types.py @@ -0,0 +1,193 @@ +"""$dateTrunc date-like input types: ObjectId, Timestamp, numeric boundaries, and return type.""" + +from datetime import datetime, timezone + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.date_utils import ( + oid_from_args, + ts_from_args, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DATE_EPOCH, + DATE_MS_BEFORE_EPOCH, + DATE_MS_EPOCH, + OID_MAX_SIGNED32, + OID_MIN_SIGNED32, + TS_MAX_SIGNED32, + TS_MAX_UNSIGNED32, +) + +# Property [ObjectId And Timestamp Input]: ObjectId and Timestamp inputs are truncated by their +# embedded time. +DATETRUNC_OID_TS_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "oid_trunc_day", + doc={"date": oid_from_args(2021, 3, 20, 11, 30, 5)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, + expected=datetime(2021, 3, 20, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate an ObjectId to the day", + ), + ExpressionTestCase( + "oid_trunc_hour", + doc={"date": oid_from_args(2021, 3, 20, 11, 30, 5)}, + expression={"$dateTrunc": {"date": "$date", "unit": "hour"}}, + expected=datetime(2021, 3, 20, 11, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate an ObjectId to the hour", + ), + ExpressionTestCase( + "oid_trunc_month", + doc={"date": oid_from_args(2021, 3, 20, 11, 30, 5)}, + expression={"$dateTrunc": {"date": "$date", "unit": "month"}}, + expected=datetime(2021, 3, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate an ObjectId to the month", + ), + ExpressionTestCase( + "oid_trunc_year", + doc={"date": oid_from_args(2021, 3, 20, 11, 30, 5)}, + expression={"$dateTrunc": {"date": "$date", "unit": "year"}}, + expected=datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate an ObjectId to the year", + ), + ExpressionTestCase( + "oid_with_tz", + doc={"date": oid_from_args(2021, 3, 20, 11, 30, 5)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day", "timezone": "UTC"}}, + expected=datetime(2021, 3, 20, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate an ObjectId with a timezone", + ), + ExpressionTestCase( + "ts_trunc_day", + doc={"date": ts_from_args(2021, 3, 20, 11, 30, 5)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, + expected=datetime(2021, 3, 20, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a Timestamp to the day", + ), + ExpressionTestCase( + "ts_trunc_hour", + doc={"date": ts_from_args(2021, 3, 20, 11, 30, 5)}, + expression={"$dateTrunc": {"date": "$date", "unit": "hour"}}, + expected=datetime(2021, 3, 20, 11, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a Timestamp to the hour", + ), + ExpressionTestCase( + "ts_trunc_month", + doc={"date": ts_from_args(2021, 3, 20, 11, 30, 5)}, + expression={"$dateTrunc": {"date": "$date", "unit": "month"}}, + expected=datetime(2021, 3, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a Timestamp to the month", + ), + ExpressionTestCase( + "ts_trunc_year", + doc={"date": ts_from_args(2021, 3, 20, 11, 30, 5)}, + expression={"$dateTrunc": {"date": "$date", "unit": "year"}}, + expected=datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a Timestamp to the year", + ), + ExpressionTestCase( + "ts_with_tz", + doc={"date": ts_from_args(2021, 3, 20, 11, 30, 5)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day", "timezone": "UTC"}}, + expected=datetime(2021, 3, 20, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a Timestamp with a timezone", + ), +] + +# Property [DatetimeMS And Numeric Boundaries]: DatetimeMS, max Timestamp, and signed-boundary +# ObjectId inputs truncate correctly. +DATETRUNC_NUMERIC_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "date_ms_epoch_trunc_day", + doc={"date": DATE_MS_EPOCH}, + expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, + expected=DATE_EPOCH, + msg="$dateTrunc should truncate an epoch DatetimeMS to the day", + ), + ExpressionTestCase( + "date_ms_before_epoch_trunc_day", + doc={"date": DATE_MS_BEFORE_EPOCH}, + expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, + expected=datetime(1969, 12, 31, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a pre-epoch DatetimeMS to the day", + ), + ExpressionTestCase( + "ts_max_s32_trunc_day", + doc={"date": TS_MAX_SIGNED32}, + expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, + expected=datetime(2038, 1, 19, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a max signed 32-bit Timestamp to the day", + ), + ExpressionTestCase( + "ts_max_u32_trunc_month", + doc={"date": TS_MAX_UNSIGNED32}, + expression={"$dateTrunc": {"date": "$date", "unit": "month"}}, + expected=datetime(2106, 2, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a max unsigned 32-bit Timestamp to the month", + ), + ExpressionTestCase( + "oid_max_signed32_trunc_day", + doc={"date": OID_MAX_SIGNED32}, + expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, + expected=datetime(2038, 1, 19, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a max signed 32-bit ObjectId to the day", + ), + ExpressionTestCase( + "oid_high_bit_trunc_year", + doc={"date": OID_MIN_SIGNED32}, + expression={"$dateTrunc": {"date": "$date", "unit": "year"}}, + expected=datetime(1901, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a high-bit ObjectId to the year", + ), +] + +# Property [Return Type]: $dateTrunc returns the date type regardless of the input date type. +DATETRUNC_RETURN_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "return_type_date", + expression={ + "$type": { + "$dateTrunc": { + "date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc), + "unit": "day", + } + } + }, + expected="date", + msg="$dateTrunc should return the date type for a Date input", + ), + ExpressionTestCase( + "return_type_from_timestamp", + doc={"ts": ts_from_args(2021, 3, 20, 11, 30, 5)}, + expression={"$type": {"$dateTrunc": {"date": "$ts", "unit": "day"}}}, + expected="date", + msg="$dateTrunc should return the date type for a Timestamp input", + ), + ExpressionTestCase( + "return_type_from_objectid", + doc={"oid": oid_from_args(2021, 3, 20, 11, 30, 5)}, + expression={"$type": {"$dateTrunc": {"date": "$oid", "unit": "day"}}}, + expected="date", + msg="$dateTrunc should return the date type for an ObjectId input", + ), +] + +DATETRUNC_INPUT_TYPE_TESTS: list[ExpressionTestCase] = ( + DATETRUNC_OID_TS_TESTS + DATETRUNC_NUMERIC_BOUNDARY_TESTS + DATETRUNC_RETURN_TYPE_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATETRUNC_INPUT_TYPE_TESTS)) +def test_dateTrunc_input_types(collection, test_case: ExpressionTestCase): + """Test $dateTrunc accepts date-like inputs and returns the date type.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_null.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_null.py new file mode 100644 index 000000000..dfc136a30 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_null.py @@ -0,0 +1,132 @@ +"""$dateTrunc null and missing propagation: a null or missing operand returns null.""" + +from datetime import datetime, timezone + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + MISSING, +) + +# Property [Null Handling]: a null date, unit, binSize, timezone, or week startOfWeek returns null. +DATETRUNC_NULL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_date", + doc={"date": None}, + expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, + expected=None, + msg="$dateTrunc should return null for a null date", + ), + ExpressionTestCase( + "null_unit", + expression={ + "$dateTrunc": {"date": datetime(2021, 1, 1, tzinfo=timezone.utc), "unit": None} + }, + expected=None, + msg="$dateTrunc should return null for a null unit", + ), + ExpressionTestCase( + "null_binSize", + expression={ + "$dateTrunc": { + "date": datetime(2021, 1, 1, tzinfo=timezone.utc), + "unit": "day", + "binSize": None, + } + }, + expected=None, + msg="$dateTrunc should return null for a null binSize", + ), + ExpressionTestCase( + "null_timezone", + expression={ + "$dateTrunc": { + "date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "unit": "day", + "timezone": None, + } + }, + expected=None, + msg="$dateTrunc should return null for a null timezone", + ), + ExpressionTestCase( + "null_startOfWeek_week", + expression={ + "$dateTrunc": { + "date": datetime(2021, 1, 1, tzinfo=timezone.utc), + "unit": "week", + "startOfWeek": None, + } + }, + expected=None, + msg="$dateTrunc should return null for a null startOfWeek with the week unit", + ), +] + +# Property [Missing Field Reference]: a missing field reference for date or any optional parameter +# returns null. +DATETRUNC_MISSING_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "missing_date_ref", + expression={"$dateTrunc": {"date": MISSING, "unit": "day"}}, + expected=None, + msg="$dateTrunc should return null for a missing date field reference", + ), + ExpressionTestCase( + "missing_binSize_ref", + expression={ + "$dateTrunc": { + "date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc), + "unit": "hour", + "binSize": MISSING, + } + }, + expected=None, + msg="$dateTrunc should return null for a missing binSize field reference", + ), + ExpressionTestCase( + "missing_timezone_ref", + expression={ + "$dateTrunc": { + "date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc), + "unit": "day", + "timezone": MISSING, + } + }, + expected=None, + msg="$dateTrunc should return null for a missing timezone field reference", + ), + ExpressionTestCase( + "missing_startOfWeek_ref", + expression={ + "$dateTrunc": { + "date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc), + "unit": "week", + "startOfWeek": MISSING, + } + }, + expected=None, + msg="$dateTrunc should return null for a missing startOfWeek field reference", + ), +] + +DATETRUNC_NULL_MISSING_TESTS: list[ExpressionTestCase] = ( + DATETRUNC_NULL_TESTS + DATETRUNC_MISSING_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATETRUNC_NULL_MISSING_TESTS)) +def test_dateTrunc_null(collection, test_case: ExpressionTestCase): + """Test $dateTrunc returns null for null literals and missing field references.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_range.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_range.py new file mode 100644 index 000000000..160ab0ac7 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_range.py @@ -0,0 +1,146 @@ +"""$dateTrunc across the date range: epoch, distant, leap-year, and far-future dates.""" + +from datetime import datetime, timezone + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.date_utils import ( + oid_from_args, + ts_from_args, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DATE_EPOCH, + DATE_YEAR_1900, +) + +# Property [Epoch And Distant Dates]: epoch, pre-epoch, distant past, and distant future dates +# truncate correctly, including from ObjectId and Timestamp inputs. +DATETRUNC_EPOCH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "epoch", + doc={"date": datetime(1970, 1, 1, 12, 30, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, + expected=DATE_EPOCH, + msg="$dateTrunc should truncate an epoch-day date", + ), + ExpressionTestCase( + "pre_epoch", + doc={"date": datetime(1969, 6, 15, 10, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "month"}}, + expected=datetime(1969, 6, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a pre-epoch date", + ), + ExpressionTestCase( + "distant_past", + doc={"date": datetime(1900, 3, 15, 8, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "year"}}, + expected=DATE_YEAR_1900, + msg="$dateTrunc should truncate a distant past date", + ), + ExpressionTestCase( + "distant_future", + doc={"date": datetime(2100, 7, 20, 15, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "quarter"}}, + expected=datetime(2100, 7, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a distant future date", + ), + ExpressionTestCase( + "oid_epoch", + doc={"date": oid_from_args(1970, 1, 1, 12, 30, 0)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, + expected=DATE_EPOCH, + msg="$dateTrunc should truncate an epoch ObjectId", + ), + ExpressionTestCase( + "ts_epoch", + doc={"date": ts_from_args(1970, 1, 1, 12, 30, 0)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, + expected=DATE_EPOCH, + msg="$dateTrunc should truncate an epoch Timestamp", + ), + ExpressionTestCase( + "oid_future", + doc={"date": oid_from_args(2035, 7, 20, 15, 0, 0)}, + expression={"$dateTrunc": {"date": "$date", "unit": "year"}}, + expected=datetime(2035, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a future ObjectId", + ), + ExpressionTestCase( + "ts_future", + doc={"date": ts_from_args(2100, 7, 20, 15, 0, 0)}, + expression={"$dateTrunc": {"date": "$date", "unit": "year"}}, + expected=datetime(2100, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a future Timestamp", + ), +] + +# Property [Leap Year]: leap-day dates truncate correctly, including century leap-year rules. +DATETRUNC_LEAP_YEAR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "leap_day_trunc_day", + doc={"date": datetime(2020, 2, 29, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, + expected=datetime(2020, 2, 29, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a leap day to the day start", + ), + ExpressionTestCase( + "leap_day_trunc_month", + doc={"date": datetime(2020, 2, 29, 23, 59, 59, 999000, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "month"}}, + expected=datetime(2020, 2, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a leap day to the month start", + ), + ExpressionTestCase( + "century_non_leap_1900_trunc_month", + doc={"date": datetime(1900, 2, 28, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "month"}}, + expected=datetime(1900, 2, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a 1900 February date to the month start", + ), + ExpressionTestCase( + "century_leap_2000_trunc_month", + doc={"date": datetime(2000, 2, 29, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "month"}}, + expected=datetime(2000, 2, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a 2000 leap day to the month start", + ), +] + +# Property [Far Future]: dates in year 9999 truncate correctly. +DATETRUNC_FAR_FUTURE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "far_future_year", + doc={"date": datetime(9999, 12, 31, 23, 59, 59, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "year"}}, + expected=datetime(9999, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a year 9999 date to the year start", + ), + ExpressionTestCase( + "far_future_quarter", + doc={"date": datetime(9999, 12, 31, 23, 59, 59, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "quarter"}}, + expected=datetime(9999, 10, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a year 9999 date to the Q4 start", + ), +] + +DATETRUNC_RANGE_TESTS: list[ExpressionTestCase] = ( + DATETRUNC_EPOCH_TESTS + DATETRUNC_LEAP_YEAR_TESTS + DATETRUNC_FAR_FUTURE_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATETRUNC_RANGE_TESTS)) +def test_dateTrunc_range(collection, test_case: ExpressionTestCase): + """Test $dateTrunc truncates correctly across epoch, leap-year, and far-future dates.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_timezone.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_timezone.py new file mode 100644 index 000000000..bee712885 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_timezone.py @@ -0,0 +1,221 @@ +"""$dateTrunc timezone handling: named zones, numeric offsets, and DST transitions.""" + +from datetime import datetime, timezone + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Timezone Extremes]: out-of-range two-digit offsets and additional named zones are +# accepted. +DATETRUNC_TIMEZONE_ACCEPT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_over60_minutes_positive", + doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "hour", "timezone": "+05:70"}}, + expected=datetime(2021, 6, 15, 11, 50, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept a +05:70 offset with over-60 minutes", + ), + ExpressionTestCase( + "tz_over60_minutes_negative", + doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "hour", "timezone": "-05:70"}}, + expected=datetime(2021, 6, 15, 11, 10, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept a -05:70 offset with over-60 minutes", + ), + ExpressionTestCase( + "tz_over24_hours_positive", + doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "hour", "timezone": "+25:00"}}, + expected=datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept a +25:00 offset with over-24 hours", + ), + ExpressionTestCase( + "tz_over24_hours_negative", + doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "hour", "timezone": "-25:00"}}, + expected=datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept a -25:00 offset with over-24 hours", + ), + ExpressionTestCase( + "tz_max_valid_positive", + doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "hour", "timezone": "+99:99"}}, + expected=datetime(2021, 6, 15, 11, 21, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept the maximum two-digit +99:99 offset", + ), + ExpressionTestCase( + "tz_max_valid_negative", + doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "hour", "timezone": "-99:99"}}, + expected=datetime(2021, 6, 15, 11, 39, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept the maximum two-digit -99:99 offset", + ), + ExpressionTestCase( + "tz_pacific_apia", + doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "hour", "timezone": "Pacific/Apia"}}, + expected=datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept the Pacific/Apia named zone", + ), + ExpressionTestCase( + "tz_offset_45min", + doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "hour", "timezone": "+05:45"}}, + expected=datetime(2021, 6, 15, 11, 15, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept a +05:45 offset with 45 minutes", + ), + ExpressionTestCase( + "tz_offset_half_hour_west", + doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "hour", "timezone": "-02:30"}}, + expected=datetime(2021, 6, 15, 11, 30, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept a -02:30 half-hour offset", + ), + ExpressionTestCase( + "tz_offset_max_east", + doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "hour", "timezone": "+14:00"}}, + expected=datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept the +14:00 maximum east offset", + ), + ExpressionTestCase( + "tz_offset_max_west", + doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "hour", "timezone": "-11:00"}}, + expected=datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept the -11:00 maximum west offset", + ), +] + +# Property [Timezone Offset]: named zones and numeric offsets shift the truncation boundary. +DATETRUNC_TIMEZONE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_shifts_day", + doc={"date": datetime(2021, 3, 20, 2, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day", "timezone": "-05:00"}}, + expected=datetime(2021, 3, 19, 5, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should shift the day boundary with a negative offset", + ), + ExpressionTestCase( + "tz_gmt", + doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day", "timezone": "GMT"}}, + expected=datetime(2021, 6, 15, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept the GMT timezone", + ), + ExpressionTestCase( + "tz_utc", + doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day", "timezone": "UTC"}}, + expected=datetime(2021, 6, 15, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept the UTC timezone", + ), + ExpressionTestCase( + "tz_offset_half_hour_east", + doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day", "timezone": "+05:30"}}, + expected=datetime(2021, 6, 14, 18, 30, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate with a +05:30 offset", + ), + ExpressionTestCase( + "tz_zero_offset", + doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day", "timezone": "+00:00"}}, + expected=datetime(2021, 6, 15, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should treat a +00:00 offset as UTC", + ), + ExpressionTestCase( + "tz_offset_no_colon", + doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day", "timezone": "-0500"}}, + expected=datetime(2021, 6, 15, 5, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept a no-colon offset format", + ), + ExpressionTestCase( + "tz_offset_hour_only", + doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day", "timezone": "+03"}}, + expected=datetime(2021, 6, 14, 21, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept an hour-only offset format", + ), + ExpressionTestCase( + "tz_kolkata_day", + doc={"date": datetime(2021, 6, 15, 20, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day", "timezone": "Asia/Kolkata"}}, + expected=datetime(2021, 6, 15, 18, 30, 0, tzinfo=timezone.utc), + msg="$dateTrunc should handle a half-hour named zone for day truncation", + ), +] + +# Property [DST Day Truncation]: day truncation in a DST zone accounts for the transition. +DATETRUNC_DST_DAY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "dst_spring_forward_day", + doc={"date": datetime(2021, 3, 14, 7, 30, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day", "timezone": "America/New_York"}}, + expected=datetime(2021, 3, 14, 5, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should handle DST spring-forward day truncation", + ), + ExpressionTestCase( + "dst_fall_back_day", + doc={"date": datetime(2021, 11, 7, 6, 30, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day", "timezone": "America/New_York"}}, + expected=datetime(2021, 11, 7, 4, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should handle DST fall-back day truncation", + ), +] + +# Property [DST Sub-Day Units]: hour and minute truncation are unaffected by DST transitions. +DATETRUNC_DST_SUBDAY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "dst_spring_hour_trunc", + doc={"date": datetime(2021, 3, 14, 7, 30, 0, tzinfo=timezone.utc)}, + expression={ + "$dateTrunc": {"date": "$date", "unit": "hour", "timezone": "America/New_York"} + }, + expected=datetime(2021, 3, 14, 7, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate the hour across DST spring-forward without adjustment", + ), + ExpressionTestCase( + "dst_spring_minute_trunc", + doc={"date": datetime(2021, 3, 14, 7, 0, 30, tzinfo=timezone.utc)}, + expression={ + "$dateTrunc": {"date": "$date", "unit": "minute", "timezone": "America/New_York"} + }, + expected=datetime(2021, 3, 14, 7, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate the minute across DST spring-forward without adjustment", + ), + ExpressionTestCase( + "dst_fall_back_hour_trunc", + doc={"date": datetime(2021, 11, 7, 6, 30, 0, tzinfo=timezone.utc)}, + expression={ + "$dateTrunc": {"date": "$date", "unit": "hour", "timezone": "America/New_York"} + }, + expected=datetime(2021, 11, 7, 6, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate the hour across DST fall-back without adjustment", + ), +] + +DATETRUNC_TIMEZONE_ALL_TESTS: list[ExpressionTestCase] = ( + DATETRUNC_TIMEZONE_ACCEPT_TESTS + + DATETRUNC_TIMEZONE_TESTS + + DATETRUNC_DST_DAY_TESTS + + DATETRUNC_DST_SUBDAY_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATETRUNC_TIMEZONE_ALL_TESTS)) +def test_dateTrunc_timezone(collection, test_case: ExpressionTestCase): + """Test $dateTrunc shifts the truncation boundary by timezone and DST.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_truncation.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_truncation.py new file mode 100644 index 000000000..c5e23d500 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_truncation.py @@ -0,0 +1,264 @@ +"""$dateTrunc truncation semantics: unit periods, bin multiples, alignment, and idempotence.""" + +from datetime import datetime, timezone + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import DATE_Y2K + +# Property [Unit Truncation]: truncating to a unit returns the start of the period containing +# the date. +DATETRUNC_UNIT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "unit_year", + doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "year"}}, + expected=datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate to the start of the year", + ), + ExpressionTestCase( + "unit_quarter_q1", + doc={"date": datetime(2021, 2, 15, 11, 30, 5, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "quarter"}}, + expected=datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a Q1 date to the Q1 start", + ), + ExpressionTestCase( + "unit_quarter_q2", + doc={"date": datetime(2021, 5, 15, 11, 30, 5, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "quarter"}}, + expected=datetime(2021, 4, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a Q2 date to the Q2 start", + ), + ExpressionTestCase( + "unit_quarter_q3", + doc={"date": datetime(2021, 8, 15, 11, 30, 5, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "quarter"}}, + expected=datetime(2021, 7, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a Q3 date to the Q3 start", + ), + ExpressionTestCase( + "unit_quarter_q4", + doc={"date": datetime(2021, 11, 15, 11, 30, 5, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "quarter"}}, + expected=datetime(2021, 10, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a Q4 date to the Q4 start", + ), + ExpressionTestCase( + "unit_month", + doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "month"}}, + expected=datetime(2021, 3, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate to the start of the month", + ), + ExpressionTestCase( + "unit_day", + doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, + expected=datetime(2021, 3, 20, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate to the start of the day", + ), + ExpressionTestCase( + "unit_hour", + doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "hour"}}, + expected=datetime(2021, 3, 20, 11, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate to the start of the hour", + ), + ExpressionTestCase( + "unit_minute", + doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "minute"}}, + expected=datetime(2021, 3, 20, 11, 30, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate to the start of the minute", + ), + ExpressionTestCase( + "unit_second", + doc={"date": datetime(2021, 3, 20, 11, 30, 5, 500000, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "second"}}, + expected=datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc), + msg="$dateTrunc should truncate to the start of the second", + ), + ExpressionTestCase( + "unit_millisecond", + doc={"date": datetime(2021, 1, 1, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "millisecond"}}, + expected=datetime(2021, 1, 1, tzinfo=timezone.utc), + msg="$dateTrunc should truncate to the millisecond", + ), +] + +# Property [BinSize Multiple]: a binSize greater than one truncates to multiples of the unit. +DATETRUNC_BINSIZE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "bin_2hour", + doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "hour", "binSize": 2}}, + expected=datetime(2021, 3, 20, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate to a 2-hour bin", + ), + ExpressionTestCase( + "bin_10year", + doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "year", "binSize": 10}}, + expected=datetime(2020, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate to a 10-year bin", + ), + ExpressionTestCase( + "bin_6month", + doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "month", "binSize": 6}}, + expected=datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate to a 6-month bin", + ), + ExpressionTestCase( + "bin_15min", + doc={"date": datetime(2021, 3, 20, 11, 37, 5, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "minute", "binSize": 15}}, + expected=datetime(2021, 3, 20, 11, 30, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate to a 15-minute bin", + ), +] + +# Property [Bin Alignment]: bins are aligned relative to the 2000-01-01 reference date, extending +# backward before it. +DATETRUNC_BIN_ALIGNMENT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "bin_5year_at_ref", + doc={"date": DATE_Y2K}, + expression={"$dateTrunc": {"date": "$date", "unit": "year", "binSize": 5}}, + expected=DATE_Y2K, + msg="$dateTrunc should align a 5-year bin to the reference date", + ), + ExpressionTestCase( + "bin_5year_before_ref", + doc={"date": datetime(1999, 12, 31, 23, 59, 59, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "year", "binSize": 5}}, + expected=datetime(1995, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should extend 5-year bins backward before the reference date", + ), + ExpressionTestCase( + "bin_5year_next_bin", + doc={"date": datetime(2005, 1, 1, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "year", "binSize": 5}}, + expected=datetime(2005, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should align to the next 5-year bin", + ), + ExpressionTestCase( + "bin_3month_align", + doc={"date": datetime(2000, 2, 15, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "month", "binSize": 3}}, + expected=DATE_Y2K, + msg="$dateTrunc should align a 3-month bin from the reference date", + ), + ExpressionTestCase( + "bin_3month_next", + doc={"date": datetime(2000, 4, 1, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "month", "binSize": 3}}, + expected=datetime(2000, 4, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should align to the next 3-month bin", + ), +] + +# Property [Boundary Idempotence]: a date already at a unit boundary is returned unchanged. +DATETRUNC_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "year_at_boundary", + doc={"date": datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "year"}}, + expected=datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should return the date unchanged at a year boundary", + ), + ExpressionTestCase( + "month_at_boundary", + doc={"date": datetime(2021, 6, 1, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "month"}}, + expected=datetime(2021, 6, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should return the date unchanged at a month boundary", + ), + ExpressionTestCase( + "day_at_boundary", + doc={"date": datetime(2021, 6, 15, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, + expected=datetime(2021, 6, 15, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should return the date unchanged at a day boundary", + ), + ExpressionTestCase( + "hour_at_boundary", + doc={"date": datetime(2021, 6, 15, 10, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "hour"}}, + expected=datetime(2021, 6, 15, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should return the date unchanged at an hour boundary", + ), + ExpressionTestCase( + "minute_at_boundary", + doc={"date": datetime(2021, 6, 15, 10, 30, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "minute"}}, + expected=datetime(2021, 6, 15, 10, 30, 0, tzinfo=timezone.utc), + msg="$dateTrunc should return the date unchanged at a minute boundary", + ), + ExpressionTestCase( + "second_at_boundary", + doc={"date": datetime(2021, 6, 15, 10, 30, 45, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "second"}}, + expected=datetime(2021, 6, 15, 10, 30, 45, tzinfo=timezone.utc), + msg="$dateTrunc should return the date unchanged at a second boundary", + ), +] + +# Property [End Of Period]: a date at the end of a period truncates to that period's start. +DATETRUNC_END_OF_PERIOD_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "year_end", + doc={"date": datetime(2021, 12, 31, 23, 59, 59, 999000, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "year"}}, + expected=datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate the last millisecond of a year to the year start", + ), + ExpressionTestCase( + "quarter_end_q1", + doc={"date": datetime(2021, 3, 31, 23, 59, 59, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "quarter"}}, + expected=datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate the end of Q1 to the Q1 start", + ), + ExpressionTestCase( + "quarter_start_q2", + doc={"date": datetime(2021, 4, 1, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "quarter"}}, + expected=datetime(2021, 4, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should return the Q2 start at the exact boundary", + ), + ExpressionTestCase( + "day_end", + doc={"date": datetime(2021, 6, 15, 23, 59, 59, 999000, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, + expected=datetime(2021, 6, 15, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate the last millisecond of a day to the day start", + ), +] + +DATETRUNC_TRUNCATION_TESTS: list[ExpressionTestCase] = ( + DATETRUNC_UNIT_TESTS + + DATETRUNC_BINSIZE_TESTS + + DATETRUNC_BIN_ALIGNMENT_TESTS + + DATETRUNC_BOUNDARY_TESTS + + DATETRUNC_END_OF_PERIOD_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATETRUNC_TRUNCATION_TESTS)) +def test_dateTrunc_truncation(collection, test_case: ExpressionTestCase): + """Test $dateTrunc returns the start of the period, honoring binSize and alignment.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_week.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_week.py new file mode 100644 index 000000000..0ab4f3790 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_week.py @@ -0,0 +1,203 @@ +"""$dateTrunc week truncation: startOfWeek values, casing, relevance, and non-week gating.""" + +from datetime import datetime, timezone + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [StartOfWeek Case]: startOfWeek accepts full day names and three-letter abbreviations +# in any case. +DATETRUNC_STARTOFWEEK_ACCEPT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "sow_mixed_case_Monday", + doc={"date": datetime(2021, 6, 16, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "Monday"}}, + expected=datetime(2021, 6, 14, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept a mixed-case Monday", + ), + ExpressionTestCase( + "sow_uppercase_MONDAY", + doc={"date": datetime(2021, 6, 16, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "MONDAY"}}, + expected=datetime(2021, 6, 14, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept an uppercase MONDAY", + ), + ExpressionTestCase( + "sow_abbrev_MON", + doc={"date": datetime(2021, 6, 16, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "MON"}}, + expected=datetime(2021, 6, 14, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept an uppercase abbreviation MON", + ), + ExpressionTestCase( + "sow_mixed_case_Friday", + doc={"date": datetime(2021, 6, 16, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "Friday"}}, + expected=datetime(2021, 6, 11, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept a mixed-case Friday", + ), + ExpressionTestCase( + "sow_uppercase_FRIDAY", + doc={"date": datetime(2021, 6, 16, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "FRIDAY"}}, + expected=datetime(2021, 6, 11, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept an uppercase FRIDAY", + ), + ExpressionTestCase( + "sow_abbrev_FRI", + doc={"date": datetime(2021, 6, 16, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "FRI"}}, + expected=datetime(2021, 6, 11, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept an uppercase abbreviation FRI", + ), + ExpressionTestCase( + "sow_uppercase_SUNDAY", + doc={"date": datetime(2021, 6, 16, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "SUNDAY"}}, + expected=datetime(2021, 6, 13, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept an uppercase SUNDAY", + ), + ExpressionTestCase( + "sow_abbrev_SUN", + doc={"date": datetime(2021, 6, 16, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "SUN"}}, + expected=datetime(2021, 6, 13, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept an uppercase abbreviation SUN", + ), +] + +# Property [StartOfWeek Relevance]: for a non-week unit a null startOfWeek is ignored when the date +# is a constant literal, but short-circuits the result to null when the date is a field reference. +DATETRUNC_STARTOFWEEK_RELEVANCE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "sow_relevance_literal_day", + expression={ + "$dateTrunc": { + "date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc), + "unit": "day", + "startOfWeek": None, + } + }, + expected=datetime(2021, 3, 20, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should ignore a null startOfWeek for a non-week unit when the date is a " + "constant", + ), + ExpressionTestCase( + "sow_relevance_field_ref_day", + doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day", "startOfWeek": None}}, + expected=None, + msg="$dateTrunc should return null for a null startOfWeek on a non-week unit when the date " + "is a field reference", + ), +] + +# Property [Week Truncation]: the week unit truncates to the configured start of week, defaulting +# to Sunday. +DATETRUNC_WEEK_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "week_default_sun", + doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "week"}}, + expected=datetime(2021, 3, 14, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should default the week start to Sunday", + ), + ExpressionTestCase( + "week_monday", + doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "monday"}}, + expected=datetime(2021, 3, 15, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate the week to Monday", + ), + ExpressionTestCase( + "week_friday", + doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "friday"}}, + expected=datetime(2021, 3, 19, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate the week to Friday", + ), + ExpressionTestCase( + "week_sunday", + doc={"date": datetime(2021, 6, 16, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "sunday"}}, + expected=datetime(2021, 6, 13, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate the week to Sunday", + ), + ExpressionTestCase( + "week_tuesday", + doc={"date": datetime(2021, 6, 16, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "tuesday"}}, + expected=datetime(2021, 6, 15, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate the week to Tuesday", + ), + ExpressionTestCase( + "week_wednesday", + doc={"date": datetime(2021, 6, 16, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "wednesday"}}, + expected=datetime(2021, 6, 16, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate the week to Wednesday", + ), + ExpressionTestCase( + "week_thursday", + doc={"date": datetime(2021, 6, 16, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "thursday"}}, + expected=datetime(2021, 6, 10, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate the week to Thursday", + ), + ExpressionTestCase( + "week_saturday", + doc={"date": datetime(2021, 6, 16, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "saturday"}}, + expected=datetime(2021, 6, 12, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate the week to Saturday", + ), + ExpressionTestCase( + "week_mon_abbrev", + doc={"date": datetime(2021, 6, 16, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "mon"}}, + expected=datetime(2021, 6, 14, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept the mon abbreviation for startOfWeek", + ), + ExpressionTestCase( + "week_monday_mixed_case", + doc={"date": datetime(2021, 6, 16, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "Monday"}}, + expected=datetime(2021, 6, 14, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept a mixed-case Monday for startOfWeek", + ), +] + +# Property [StartOfWeek Ignored]: startOfWeek has no effect for a non-week unit. +DATETRUNC_SOW_IGNORED_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "sow_ignored_month", + doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "month", "startOfWeek": "friday"}}, + expected=datetime(2021, 3, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should ignore startOfWeek for the month unit", + ), +] + +DATETRUNC_WEEK_ALL_TESTS: list[ExpressionTestCase] = ( + DATETRUNC_STARTOFWEEK_ACCEPT_TESTS + + DATETRUNC_STARTOFWEEK_RELEVANCE_TESTS + + DATETRUNC_WEEK_TESTS + + DATETRUNC_SOW_IGNORED_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATETRUNC_WEEK_ALL_TESTS)) +def test_dateTrunc_week(collection, test_case: ExpressionTestCase): + """Test $dateTrunc week truncation honors startOfWeek and ignores it otherwise.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/test_dateAdd_dateSubtract_equivalence.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/test_dateAdd_dateSubtract_equivalence.py new file mode 100644 index 000000000..d8188a6e9 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/test_dateAdd_dateSubtract_equivalence.py @@ -0,0 +1,157 @@ +"""$dateAdd / $dateSubtract equivalence under amount negation, plus add/subtract roundtrips.""" + +from datetime import datetime, timezone + +import pytest +from bson import Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Negation Equivalence]: $dateAdd(date, unit, N) equals $dateSubtract(date, unit, -N), +# verified server-side with $eq. +DATEADD_EQUIVALENCE_TESTS: list[ExpressionTestCase] = [ + *[ + ExpressionTestCase( + f"negation_{unit}_{amount}", + expression={ + "$eq": [ + { + "$dateAdd": { + "startDate": datetime(2021, 6, 15, 12, 30, 45, tzinfo=timezone.utc), + "unit": unit, + "amount": amount, + } + }, + { + "$dateSubtract": { + "startDate": datetime(2021, 6, 15, 12, 30, 45, tzinfo=timezone.utc), + "unit": unit, + "amount": -amount, + } + }, + ] + }, + expected=True, + msg=f"$dateAdd should equal $dateSubtract with a negated {unit} amount", + ) + for unit, amount in [ + ("year", 1), + ("quarter", 2), + ("month", 3), + ("week", 4), + ("day", 10), + ("hour", 24), + ("minute", 120), + ("second", 3600), + ("millisecond", 5000), + ] + ], + ExpressionTestCase( + "negation_with_timezone", + expression={ + "$eq": [ + { + "$dateAdd": { + "startDate": datetime(2021, 3, 14, 10, 0, 0, tzinfo=timezone.utc), + "unit": "day", + "amount": 1, + "timezone": "America/New_York", + } + }, + { + "$dateSubtract": { + "startDate": datetime(2021, 3, 14, 10, 0, 0, tzinfo=timezone.utc), + "unit": "day", + "amount": -1, + "timezone": "America/New_York", + } + }, + ] + }, + expected=True, + msg="$dateAdd should equal $dateSubtract with a negated amount and a timezone", + ), + ExpressionTestCase( + "negation_int64_amount", + expression={ + "$eq": [ + { + "$dateAdd": { + "startDate": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "unit": "second", + "amount": Int64(86400), + } + }, + { + "$dateSubtract": { + "startDate": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "unit": "second", + "amount": Int64(-86400), + } + }, + ] + }, + expected=True, + msg="$dateAdd should equal $dateSubtract with negated Int64 amounts", + ), +] + +# Property [Roundtrip]: adding then subtracting the same amount returns the original date, +# reflecting end-of-month clamping. +DATEADD_ROUNDTRIP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "roundtrip_day_30", + expression={ + "$dateSubtract": { + "startDate": { + "$dateAdd": { + "startDate": datetime(2021, 6, 15, 12, 30, 45, tzinfo=timezone.utc), + "unit": "day", + "amount": 30, + } + }, + "unit": "day", + "amount": 30, + } + }, + expected=datetime(2021, 6, 15, 12, 30, 45, tzinfo=timezone.utc), + msg="add then subtract of the same amount should return the original date", + ), + ExpressionTestCase( + "roundtrip_month_clamping", + expression={ + "$dateSubtract": { + "startDate": { + "$dateAdd": { + "startDate": datetime(2021, 1, 31, 12, 0, 0, tzinfo=timezone.utc), + "unit": "month", + "amount": 1, + } + }, + "unit": "month", + "amount": 1, + } + }, + # Jan 31 + 1 month clamps to Feb 28, and Feb 28 - 1 month lands on Jan 28, not Jan 31. + expected=datetime(2021, 1, 28, 12, 0, 0, tzinfo=timezone.utc), + msg="a month roundtrip should reflect end-of-month clamping", + ), +] + +DATEADD_DATESUBTRACT_TESTS = DATEADD_EQUIVALENCE_TESTS + DATEADD_ROUNDTRIP_TESTS + + +@pytest.mark.parametrize("test_case", pytest_params(DATEADD_DATESUBTRACT_TESTS)) +def test_dateAdd_dateSubtract(collection, test_case: ExpressionTestCase): + """Test $dateAdd and $dateSubtract equivalence and roundtrips.""" + result = execute_expression(collection, test_case.expression) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_date_operators.py b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_date_operators.py new file mode 100644 index 000000000..194b5f9d0 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_date_operators.py @@ -0,0 +1,138 @@ +"""Cross-operator combinations of $dateAdd/$dateSubtract with $cond, $let, $abs, and nesting.""" + +from datetime import datetime, timezone + +import pytest +from bson import Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Cross-Operator Composition]: date operators compose with $cond, $let, $abs, +# $dateFromString, and each other. +DATE_COMBINATION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "dateAdd_inside_cond", + doc={"date": datetime(2020, 12, 31, 12, 0, 0, tzinfo=timezone.utc), "active": True}, + expression={ + "$cond": { + "if": "$active", + "then": {"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1}}, + "else": "$date", + } + }, + expected=datetime(2021, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should compose inside $cond", + ), + ExpressionTestCase( + "dateAdd_with_let", + expression={ + "$let": { + "vars": {"d": datetime(2020, 12, 31, 12, 0, 0, tzinfo=timezone.utc)}, + "in": {"$dateAdd": {"startDate": "$$d", "unit": "day", "amount": 1}}, + } + }, + expected=datetime(2021, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should accept a $let variable as startDate", + ), + ExpressionTestCase( + "dateAdd_nested_literal", + expression={ + "$dateAdd": { + "startDate": {"$literal": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + "unit": "day", + "amount": 5, + } + }, + expected=datetime(2000, 1, 6, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should accept a nested $literal startDate", + ), + ExpressionTestCase( + "dateAdd_nested_abs", + expression={ + "$dateAdd": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "unit": "day", + "amount": {"$abs": -5}, + } + }, + expected=datetime(2000, 1, 6, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should accept a nested $abs amount", + ), + ExpressionTestCase( + "dateAdd_nested_dateAdd", + doc={"date": datetime(2020, 12, 31, 12, 10, 5, tzinfo=timezone.utc)}, + expression={ + "$dateAdd": { + "startDate": {"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1}}, + "unit": "day", + "amount": 1, + } + }, + expected=datetime(2021, 1, 2, 12, 10, 5, tzinfo=timezone.utc), + msg="nested $dateAdd should accumulate additions", + ), + ExpressionTestCase( + "dateSubtract_from_expression", + expression={ + "$dateSubtract": { + "startDate": {"$dateFromString": {"dateString": "2021-06-15"}}, + "unit": "day", + "amount": 1, + } + }, + expected=datetime(2021, 6, 14, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should accept an expression operator as startDate", + ), + ExpressionTestCase( + "dateSubtract_nested_literal", + expression={ + "$dateSubtract": { + "startDate": {"$literal": datetime(2000, 1, 6, 12, 0, 0, tzinfo=timezone.utc)}, + "unit": "day", + "amount": 5, + } + }, + expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should accept a nested $literal startDate", + ), + ExpressionTestCase( + "dateSubtract_nested_abs", + expression={ + "$dateSubtract": { + "startDate": datetime(2000, 1, 6, 12, 0, 0, tzinfo=timezone.utc), + "unit": "day", + "amount": {"$abs": -5}, + } + }, + expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should accept a nested $abs amount", + ), + ExpressionTestCase( + "dateDiff_from_expression", + expression={ + "$dateDiff": { + "startDate": {"$dateFromString": {"dateString": "2021-01-01"}}, + "endDate": {"$dateFromString": {"dateString": "2021-01-02"}}, + "unit": "day", + } + }, + expected=Int64(1), + msg="$dateDiff should accept expression operators as date inputs", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(DATE_COMBINATION_TESTS)) +def test_date_operator_combination(collection, test_case: ExpressionTestCase): + """Test date expression operators composed with other operators.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) From 2d891093696ce5281ae83ad7f65cd83b1623551a Mon Sep 17 00:00:00 2001 From: Victor Tsang Date: Mon, 13 Jul 2026 12:16:38 -0700 Subject: [PATCH 22/35] Add admin command tests for setClusterParameter (#646) Signed-off-by: Victor [C] Tsang Signed-off-by: RyanGarfinkel <113050972+RyanGarfinkel@users.noreply.github.com> --- .../commands/setClusterParameter/__init__.py | 0 ...etClusterParameter_bson_type_validation.py | 130 +++++++++ .../test_setClusterParameter_core_behavior.py | 256 ++++++++++++++++++ .../test_setClusterParameter_errors.py | 175 ++++++++++++ .../test_smoke_setClusterParameter.py | 45 +-- 5 files changed, 584 insertions(+), 22 deletions(-) create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/setClusterParameter/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/setClusterParameter/test_setClusterParameter_bson_type_validation.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/setClusterParameter/test_setClusterParameter_core_behavior.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/setClusterParameter/test_setClusterParameter_errors.py diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/setClusterParameter/__init__.py b/documentdb_tests/compatibility/tests/system/administration/commands/setClusterParameter/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/setClusterParameter/test_setClusterParameter_bson_type_validation.py b/documentdb_tests/compatibility/tests/system/administration/commands/setClusterParameter/test_setClusterParameter_bson_type_validation.py new file mode 100644 index 000000000..021795546 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/setClusterParameter/test_setClusterParameter_bson_type_validation.py @@ -0,0 +1,130 @@ +"""Tests for setClusterParameter BSON type validation. + +The setClusterParameter command value field accepts only object (document) type. +Parameter value fields each accept specific BSON types. +""" + +import pytest +from bson import Int64 +from bson.decimal128 import Decimal128 + +from documentdb_tests.framework.assertions import assertFailureCode, assertSuccessPartial +from documentdb_tests.framework.bson_type_validator import ( + BsonType, + BsonTypeTestCase, + generate_bson_acceptance_test_cases, + generate_bson_rejection_test_cases, +) +from documentdb_tests.framework.error_codes import BAD_VALUE_ERROR, TYPE_MISMATCH_ERROR +from documentdb_tests.framework.executor import execute_admin_command + +pytestmark = [pytest.mark.admin, pytest.mark.no_parallel] + +# NULL is skipped from the rejection matrices below: its behavior is field-dependent and does not +# cleanly map to success or failure, so it is covered by dedicated tests in the other files. +BSON_TYPE_SPECS = [ + BsonTypeTestCase( + id="setClusterParameter_value", + msg="setClusterParameter value should only accept object type", + keyword="setClusterParameter", + valid_types=[BsonType.OBJECT], + skip_rejection_types=[BsonType.NULL], + valid_inputs={ + BsonType.OBJECT: { + "changeStreamOptions": {"preAndPostImages": {"expireAfterSeconds": 7200}} + }, + }, + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="bool_param_pauseMigrations", + msg="pauseMigrationsDuringMultiUpdates.enabled accepts only bool", + keyword="pauseMigrationsDuringMultiUpdates", + valid_types=[BsonType.BOOL], + skip_rejection_types=[BsonType.NULL], + valid_inputs={BsonType.BOOL: {"enabled": False}}, + requires={"field": "enabled"}, + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="numeric_param_changeStreams", + msg="changeStreams.expireAfterSeconds accepts only numeric types", + keyword="changeStreams", + valid_types=[BsonType.INT, BsonType.LONG, BsonType.DOUBLE, BsonType.DECIMAL], + skip_rejection_types=[BsonType.NULL], + valid_inputs={ + BsonType.INT: {"expireAfterSeconds": 3600}, + BsonType.LONG: {"expireAfterSeconds": Int64(3600)}, + BsonType.DOUBLE: {"expireAfterSeconds": 3600.0}, + BsonType.DECIMAL: {"expireAfterSeconds": Decimal128("3600")}, + }, + requires={"field": "expireAfterSeconds"}, + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="document_param_changeStreamOptions", + msg="changeStreamOptions.preAndPostImages accepts only object type", + keyword="changeStreamOptions", + valid_types=[BsonType.OBJECT], + skip_rejection_types=[BsonType.NULL], + valid_inputs={BsonType.OBJECT: {"preAndPostImages": {"expireAfterSeconds": 7200}}}, + requires={"field": "preAndPostImages"}, + default_error_code=BAD_VALUE_ERROR, + ), +] + +ALL_REJECTIONS = generate_bson_rejection_test_cases(BSON_TYPE_SPECS) +ALL_ACCEPTANCES = generate_bson_acceptance_test_cases(BSON_TYPE_SPECS) + +_DEFAULTS = { + "changeStreamOptions": {"preAndPostImages": {"expireAfterSeconds": "off"}}, + "changeStreams": {"expireAfterSeconds": Int64(3600)}, + "pauseMigrationsDuringMultiUpdates": {"enabled": False}, +} + + +def _build_command(spec, sample_value, *, is_rejection): + """Build the setClusterParameter command for a given spec and sample value.""" + if spec.keyword == "setClusterParameter": + return {"setClusterParameter": sample_value} + if is_rejection: + field = spec.requires["field"] + return {"setClusterParameter": {spec.keyword: {field: sample_value}}} + return {"setClusterParameter": {spec.keyword: sample_value}} + + +def _restore_default(collection, spec): + """Restore a parameter to its default value after an acceptance test.""" + if spec.keyword == "setClusterParameter": + execute_admin_command( + collection, + { + "setClusterParameter": { + "changeStreamOptions": {"preAndPostImages": {"expireAfterSeconds": "off"}} + } + }, + ) + else: + if spec.keyword in _DEFAULTS: + execute_admin_command( + collection, {"setClusterParameter": {spec.keyword: _DEFAULTS[spec.keyword]}} + ) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", ALL_REJECTIONS) +def test_setClusterParameter_bson_type_rejected(collection, bson_type, sample_value, spec): + """Test setClusterParameter rejects invalid BSON types.""" + command = _build_command(spec, sample_value, is_rejection=True) + result = execute_admin_command(collection, command) + assertFailureCode(result, spec.expected_code(bson_type), msg=spec.msg) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", ALL_ACCEPTANCES) +def test_setClusterParameter_bson_type_accepted(collection, bson_type, sample_value, spec): + """Test setClusterParameter accepts valid BSON types.""" + command = _build_command(spec, sample_value, is_rejection=False) + try: + result = execute_admin_command(collection, command) + assertSuccessPartial(result, {"ok": 1.0}, msg=spec.msg) + finally: + _restore_default(collection, spec) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/setClusterParameter/test_setClusterParameter_core_behavior.py b/documentdb_tests/compatibility/tests/system/administration/commands/setClusterParameter/test_setClusterParameter_core_behavior.py new file mode 100644 index 000000000..f4ba73157 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/setClusterParameter/test_setClusterParameter_core_behavior.py @@ -0,0 +1,256 @@ +"""Tests for setClusterParameter command core behavior. + +Validates set/get round-trip, idempotent re-application, last-write-wins +semantics, reset to default, clusterParameterTime advancement, null resetting a +scalar field to its default, parameter independence, and accepted argument forms. +""" + +import pytest +from bson import Int64 + +from documentdb_tests.compatibility.tests.system.administration.utils.admin_test_case import ( + AdminTestCase, +) +from documentdb_tests.framework.assertions import ( + assertProperties, + assertResult, + assertSuccessPartial, +) +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, Gt + +pytestmark = [pytest.mark.admin, pytest.mark.no_parallel] + +PARAM_NAME = "changeStreamOptions" +DEFAULT_VALUE = {"preAndPostImages": {"expireAfterSeconds": "off"}} +ALT_VALUE_1 = {"preAndPostImages": {"expireAfterSeconds": 7200}} +ALT_VALUE_2 = {"preAndPostImages": {"expireAfterSeconds": 3600}} + + +def _set_param(collection, value): + """Set the cluster parameter.""" + return execute_admin_command(collection, {"setClusterParameter": {PARAM_NAME: value}}) + + +def _restore(collection): + """Restore default.""" + execute_admin_command(collection, {"setClusterParameter": {PARAM_NAME: DEFAULT_VALUE}}) + + +CORE_TESTS: list[AdminTestCase] = [ + AdminTestCase( + "set_valid_parameter_alt1", + command={"setClusterParameter": {PARAM_NAME: ALT_VALUE_1}}, + expected={"ok": Eq(1.0)}, + msg="Setting a valid cluster parameter (alt1) should return ok:1", + ), + AdminTestCase( + "set_valid_parameter_alt2", + command={"setClusterParameter": {PARAM_NAME: ALT_VALUE_2}}, + expected={"ok": Eq(1.0)}, + msg="Setting a valid cluster parameter (alt2) should return ok:1", + ), + AdminTestCase( + "unrecognized_top_level_field_ignored", + command={"setClusterParameter": {PARAM_NAME: ALT_VALUE_1}, "unknownField": 1}, + expected={"ok": Eq(1.0)}, + msg="Unrecognized top-level field should be ignored", + ), + AdminTestCase( + "maxTimeMS_accepted", + command={"setClusterParameter": {PARAM_NAME: ALT_VALUE_1}, "maxTimeMS": 30000}, + expected={"ok": Eq(1.0)}, + msg="maxTimeMS should be accepted", + ), + AdminTestCase( + "empty_subdocument_changeStreams_noop", + command={"setClusterParameter": {"changeStreams": {}}}, + expected={"ok": Eq(1.0)}, + msg="Empty sub-document for changeStreams is accepted as a no-op", + ), + AdminTestCase( + "changeStreamOptions_expireAfterSeconds_off_accepted", + command={ + "setClusterParameter": {PARAM_NAME: {"preAndPostImages": {"expireAfterSeconds": "off"}}} + }, + expected={"ok": Eq(1.0)}, + msg="changeStreamOptions.expireAfterSeconds accepts the string 'off'", + ), + AdminTestCase( + "changeStreamOptions_expireAfterSeconds_numeric_accepted", + command={ + "setClusterParameter": {PARAM_NAME: {"preAndPostImages": {"expireAfterSeconds": 7200}}} + }, + expected={"ok": Eq(1.0)}, + msg="changeStreamOptions.expireAfterSeconds accepts a numeric value", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(CORE_TESTS)) +def test_setClusterParameter_core(collection, test): + """Test setClusterParameter core success cases.""" + try: + result = execute_admin_command(collection, test.command) + assertResult(result, expected=test.expected, msg=test.msg, raw_res=True) + finally: + _restore(collection) + + +def test_setClusterParameter_round_trip(collection): + """Test setClusterParameter value is reported back unchanged by getClusterParameter.""" + try: + _set_param(collection, ALT_VALUE_1) + result = execute_admin_command(collection, {"getClusterParameter": PARAM_NAME}) + assertSuccessPartial( + result, + {"clusterParameters": [{"preAndPostImages": {"expireAfterSeconds": Int64(7200)}}]}, + msg="getClusterParameter should report the value just written", + ) + finally: + _restore(collection) + + +def test_setClusterParameter_round_trip_pauseMigrations(collection): + """Test pauseMigrationsDuringMultiUpdates round-trips through getClusterParameter unchanged.""" + try: + execute_admin_command( + collection, + {"setClusterParameter": {"pauseMigrationsDuringMultiUpdates": {"enabled": True}}}, + ) + result = execute_admin_command( + collection, {"getClusterParameter": "pauseMigrationsDuringMultiUpdates"} + ) + assertSuccessPartial( + result, + {"clusterParameters": [{"enabled": True}]}, + msg="getClusterParameter should report the value just written", + ) + finally: + execute_admin_command( + collection, + {"setClusterParameter": {"pauseMigrationsDuringMultiUpdates": {"enabled": False}}}, + ) + + +def test_setClusterParameter_idempotent(collection): + """Test re-applying a parameter's current value leaves it unchanged (no-op).""" + try: + _set_param(collection, ALT_VALUE_1) + _set_param(collection, ALT_VALUE_1) + result = execute_admin_command(collection, {"getClusterParameter": PARAM_NAME}) + assertSuccessPartial( + result, + {"clusterParameters": [{"preAndPostImages": {"expireAfterSeconds": Int64(7200)}}]}, + msg="Value should remain unchanged after re-applying the same value", + ) + finally: + _restore(collection) + + +def test_setClusterParameter_set_then_reset(collection): + """Test setClusterParameter resetting a parameter restores its default value.""" + try: + _set_param(collection, ALT_VALUE_1) + _set_param(collection, DEFAULT_VALUE) + result = execute_admin_command(collection, {"getClusterParameter": PARAM_NAME}) + assertSuccessPartial( + result, + {"clusterParameters": [{"preAndPostImages": {"expireAfterSeconds": "off"}}]}, + msg="Parameter should read back as its default after reset", + ) + finally: + _restore(collection) + + +def test_setClusterParameter_last_write_wins(collection): + """Test two sequential setClusterParameter calls — last write wins.""" + try: + _set_param(collection, ALT_VALUE_1) + _set_param(collection, ALT_VALUE_2) + result = execute_admin_command(collection, {"getClusterParameter": PARAM_NAME}) + assertSuccessPartial( + result, + {"clusterParameters": [{"preAndPostImages": {"expireAfterSeconds": Int64(3600)}}]}, + msg="Last write should win", + ) + finally: + _restore(collection) + + +@pytest.mark.requires(cluster_admin=True) +def test_setClusterParameter_advances_cluster_parameter_time(collection): + """Test setClusterParameter advances clusterParameterTime when the value changes.""" + _restore(collection) + before = execute_admin_command(collection, {"getClusterParameter": PARAM_NAME}) + t0 = before["clusterParameters"][0]["clusterParameterTime"] + + try: + _set_param(collection, ALT_VALUE_1) + + result = execute_admin_command(collection, {"getClusterParameter": PARAM_NAME}) + assertProperties( + result, + {"clusterParameters.0.clusterParameterTime": Gt(t0)}, + msg="clusterParameterTime should advance after a value-changing set", + raw_res=True, + ) + finally: + _restore(collection) + + +def _assert_null_resets_field(collection, param, field, non_default, default): + """Set a scalar field to a non-default, then null it, and assert it reset to default.""" + try: + execute_admin_command(collection, {"setClusterParameter": {param: {field: non_default}}}) + execute_admin_command(collection, {"setClusterParameter": {param: {field: None}}}) + result = execute_admin_command(collection, {"getClusterParameter": param}) + assertSuccessPartial( + result, + {"clusterParameters": [{field: default}]}, + msg=f"null for {param}.{field} should reset to default", + ) + finally: + execute_admin_command(collection, {"setClusterParameter": {param: {field: default}}}) + + +def test_setClusterParameter_null_resets_changeStreams_expireAfterSeconds(collection): + """Test null on changeStreams.expireAfterSeconds resets it to its default.""" + _assert_null_resets_field(collection, "changeStreams", "expireAfterSeconds", 5000, Int64(3600)) + + +def test_setClusterParameter_null_resets_pauseMigrations_enabled(collection): + """Test null on pauseMigrationsDuringMultiUpdates.enabled resets it to its default.""" + _assert_null_resets_field( + collection, "pauseMigrationsDuringMultiUpdates", "enabled", True, False + ) + + +def test_setClusterParameter_independent_parameters_do_not_interfere(collection): + """Test setting one parameter does not clobber the value of another.""" + try: + _set_param(collection, ALT_VALUE_1) + execute_admin_command( + collection, {"setClusterParameter": {"changeStreams": {"expireAfterSeconds": 5000}}} + ) + result = execute_admin_command( + collection, {"getClusterParameter": [PARAM_NAME, "changeStreams"]} + ) + assertSuccessPartial( + result, + { + "clusterParameters": [ + {"preAndPostImages": {"expireAfterSeconds": Int64(7200)}}, + {"expireAfterSeconds": Int64(5000)}, + ] + }, + msg="Each parameter should retain its own value after setting the other", + ) + finally: + _restore(collection) + # This test also mutates changeStreams, which _restore (changeStreamOptions only) + # does not cover — restore it explicitly so it doesn't leak into later tests. + execute_admin_command( + collection, {"setClusterParameter": {"changeStreams": {"expireAfterSeconds": 3600}}} + ) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/setClusterParameter/test_setClusterParameter_errors.py b/documentdb_tests/compatibility/tests/system/administration/commands/setClusterParameter/test_setClusterParameter_errors.py new file mode 100644 index 000000000..72477ff7a --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/setClusterParameter/test_setClusterParameter_errors.py @@ -0,0 +1,175 @@ +"""Tests for setClusterParameter error cases. + +Validates error scenarios including non-admin database, unknown/empty/case-variant/ +node-local parameter names, multiple parameters, invalid argument forms, wrong value +types, out-of-range and empty sub-document values, dollar-prefixed fields, and +rejected options (writeConcern, apiStrict). +""" + +import pytest + +from documentdb_tests.compatibility.tests.system.administration.utils.admin_test_case import ( + AdminTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + API_STRICT_ERROR, + BAD_VALUE_ERROR, + DOLLAR_PREFIXED_FIELD_NAME_ERROR, + INVALID_OPTIONS_ERROR, + MISSING_FIELD_ERROR, + NO_SUCH_KEY_ERROR, + UNAUTHORIZED_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = [pytest.mark.admin, pytest.mark.no_parallel] + +PARAM_NAME = "changeStreamOptions" +VALID_VALUE = {"preAndPostImages": {"expireAfterSeconds": 7200}} + + +ERROR_TESTS: list[AdminTestCase] = [ + AdminTestCase( + "multiple_parameters", + command={ + "setClusterParameter": { + PARAM_NAME: VALID_VALUE, + "changeStreams": {"expireAfterSeconds": 3600}, + } + }, + error_code=INVALID_OPTIONS_ERROR, + msg="Setting multiple parameters in one call should be rejected", + ), + AdminTestCase( + "unknown_parameter", + command={"setClusterParameter": {"unknownParam999": {"x": 1}}}, + error_code=NO_SUCH_KEY_ERROR, + msg="Unknown parameter name should be rejected", + ), + AdminTestCase( + "api_strict_rejected", + command={ + "setClusterParameter": {PARAM_NAME: VALID_VALUE}, + "apiVersion": "1", + "apiStrict": True, + }, + error_code=API_STRICT_ERROR, + msg="apiStrict mode should reject setClusterParameter", + ), + AdminTestCase( + "empty_document", + command={"setClusterParameter": {}}, + error_code=NO_SUCH_KEY_ERROR, + msg="Empty document should fail", + ), + AdminTestCase( + "null_argument", + command={"setClusterParameter": None}, + error_code=MISSING_FIELD_ERROR, + msg="Null argument should fail", + ), + AdminTestCase( + "empty_key", + command={"setClusterParameter": {"": {"x": 1}}}, + error_code=NO_SUCH_KEY_ERROR, + msg="Empty key should fail", + ), + AdminTestCase( + "wrong_type_value", + command={"setClusterParameter": {PARAM_NAME: "invalid"}}, + error_code=BAD_VALUE_ERROR, + msg="String value should fail for document-typed parameter", + ), + AdminTestCase( + "dollar_prefixed_field", + command={"setClusterParameter": {PARAM_NAME: {"$bad": 1}}}, + error_code=DOLLAR_PREFIXED_FIELD_NAME_ERROR, + msg="Dollar-prefixed field should fail", + ), + AdminTestCase( + "case_variant_parameter_name", + command={"setClusterParameter": {"ChangeStreamOptions": VALID_VALUE}}, + error_code=NO_SUCH_KEY_ERROR, + msg="Case-variant name should fail (names are case-sensitive)", + ), + AdminTestCase( + "node_local_parameter", + command={"setClusterParameter": {"logLevel": {"verbosity": 1}}}, + error_code=NO_SUCH_KEY_ERROR, + msg="Node-local parameter should be rejected by setClusterParameter", + ), + AdminTestCase( + "extra_fields_in_value", + command={ + "setClusterParameter": { + PARAM_NAME: { + "preAndPostImages": {"expireAfterSeconds": 7200}, + "extraField": "nope", + } + } + }, + error_code=BAD_VALUE_ERROR, + msg="Extra fields in parameter value should fail", + ), + AdminTestCase( + "null_document_field_value", + command={"setClusterParameter": {PARAM_NAME: {"preAndPostImages": None}}}, + error_code=BAD_VALUE_ERROR, + msg="Null for a document-typed parameter field should fail", + ), + AdminTestCase( + "zero_expireAfterSeconds", + command={"setClusterParameter": {"changeStreams": {"expireAfterSeconds": 0}}}, + error_code=BAD_VALUE_ERROR, + msg="Zero expireAfterSeconds should fail (requires a positive integer, like negative)", + ), + AdminTestCase( + "negative_expireAfterSeconds", + command={"setClusterParameter": {"changeStreams": {"expireAfterSeconds": -1}}}, + error_code=BAD_VALUE_ERROR, + msg="Negative expireAfterSeconds (out of range for a positive-int field) should fail", + ), + AdminTestCase( + "empty_subdocument_changeStreamOptions", + command={"setClusterParameter": {PARAM_NAME: {}}}, + error_code=BAD_VALUE_ERROR, + msg="Empty sub-document for changeStreamOptions (required field missing) should fail", + ), + AdminTestCase( + "changeStreamOptions_expireAfterSeconds_invalid_string", + command={ + "setClusterParameter": {PARAM_NAME: {"preAndPostImages": {"expireAfterSeconds": "on"}}} + }, + error_code=BAD_VALUE_ERROR, + msg="changeStreamOptions.expireAfterSeconds rejects non-'off' string values", + ), + AdminTestCase( + "changeStreamOptions_expireAfterSeconds_bool_rejected", + command={ + "setClusterParameter": {PARAM_NAME: {"preAndPostImages": {"expireAfterSeconds": True}}} + }, + error_code=BAD_VALUE_ERROR, + msg="changeStreamOptions.expireAfterSeconds rejects bool (expects string or numeric)", + ), + AdminTestCase( + "writeConcern_rejected", + command={"setClusterParameter": {PARAM_NAME: VALID_VALUE}, "writeConcern": {"w": 1}}, + error_code=INVALID_OPTIONS_ERROR, + msg="writeConcern should be rejected", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(ERROR_TESTS)) +def test_setClusterParameter_errors(collection, test): + """Test setClusterParameter returns the expected error code for failure scenarios.""" + result = execute_admin_command(collection, test.command) + assertResult(result, error_code=test.error_code, msg=test.msg) + + +def test_setClusterParameter_non_admin_database(collection): + """Test setClusterParameter is rejected on non-admin database.""" + result = execute_command(collection, {"setClusterParameter": {PARAM_NAME: VALID_VALUE}}) + assertResult(result, error_code=UNAUTHORIZED_ERROR, msg="Non-admin db should be rejected") diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/setClusterParameter/test_smoke_setClusterParameter.py b/documentdb_tests/compatibility/tests/system/administration/commands/setClusterParameter/test_smoke_setClusterParameter.py index ccaff72c1..3feb6b75b 100644 --- a/documentdb_tests/compatibility/tests/system/administration/commands/setClusterParameter/test_smoke_setClusterParameter.py +++ b/documentdb_tests/compatibility/tests/system/administration/commands/setClusterParameter/test_smoke_setClusterParameter.py @@ -9,7 +9,7 @@ from documentdb_tests.framework.assertions import assertSuccessPartial from documentdb_tests.framework.executor import execute_admin_command -pytestmark = [pytest.mark.smoke, pytest.mark.no_parallel] +pytestmark = [pytest.mark.smoke, pytest.mark.no_parallel, pytest.mark.requires(cluster_admin=True)] def test_smoke_setClusterParameter(collection): @@ -23,25 +23,26 @@ def test_smoke_setClusterParameter(collection): original_seconds = params[0].get("preAndPostImages", {}).get("expireAfterSeconds", 3600) new_seconds = 7200 if original_seconds != 7200 else 3600 - result = execute_admin_command( - collection, - { - "setClusterParameter": { - "changeStreamOptions": {"preAndPostImages": {"expireAfterSeconds": new_seconds}} - } - }, - ) - - expected = {"ok": 1.0} - assertSuccessPartial(result, expected, msg="Should support setClusterParameter command") - - execute_admin_command( - collection, - { - "setClusterParameter": { - "changeStreamOptions": { - "preAndPostImages": {"expireAfterSeconds": original_seconds} + try: + result = execute_admin_command( + collection, + { + "setClusterParameter": { + "changeStreamOptions": {"preAndPostImages": {"expireAfterSeconds": new_seconds}} } - } - }, - ) + }, + ) + + expected = {"ok": 1.0} + assertSuccessPartial(result, expected, msg="Should support setClusterParameter command") + finally: + execute_admin_command( + collection, + { + "setClusterParameter": { + "changeStreamOptions": { + "preAndPostImages": {"expireAfterSeconds": original_seconds} + } + } + }, + ) From bc85f3535b6c24e897278100f1259f4e530f7688 Mon Sep 17 00:00:00 2001 From: Aaron Date: Mon, 13 Jul 2026 12:22:57 -0700 Subject: [PATCH 23/35] Add $bucketAuto aggregation stage compatibility tests (#675) Signed-off-by: Aaron Gong Signed-off-by: RyanGarfinkel <113050972+RyanGarfinkel@users.noreply.github.com> --- .../test_bucketAuto_accumulator_errors.py | 73 ++++ .../bucketAuto/test_bucketAuto_arg_errors.py | 244 +++++++++++++ .../bucketAuto/test_bucketAuto_boundaries.py | 332 ++++++++++++++++++ .../test_bucketAuto_buckets_validation.py | 281 +++++++++++++++ .../test_bucketAuto_core_semantics.py | 128 +++++++ .../bucketAuto/test_bucketAuto_granularity.py | 243 +++++++++++++ .../bucketAuto/test_bucketAuto_groupby.py | 135 +++++++ .../bucketAuto/test_bucketAuto_output.py | 282 +++++++++++++++ .../test_bucketAuto_output_errors.py | 227 ++++++++++++ .../stages/bucketAuto/utils/__init__.py | 0 .../bucketAuto/utils/bucketAuto_common.py | 21 ++ .../stages/test_stages_position_bucketAuto.py | 148 ++++++++ documentdb_tests/framework/error_codes.py | 13 + 13 files changed, 2127 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/test_bucketAuto_accumulator_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/test_bucketAuto_arg_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/test_bucketAuto_boundaries.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/test_bucketAuto_buckets_validation.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/test_bucketAuto_core_semantics.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/test_bucketAuto_granularity.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/test_bucketAuto_groupby.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/test_bucketAuto_output.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/test_bucketAuto_output_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/utils/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/utils/bucketAuto_common.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/test_stages_position_bucketAuto.py diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/test_bucketAuto_accumulator_errors.py b/documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/test_bucketAuto_accumulator_errors.py new file mode 100644 index 000000000..5832f66be --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/test_bucketAuto_accumulator_errors.py @@ -0,0 +1,73 @@ +"""Tests for $bucketAuto aggregation stage — accumulator error propagation.""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, + populate_collection, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Accumulator Error Propagation]: errors raised by an accumulator +# sub-expression propagate out of $bucketAuto. +BUCKET_AUTO_ACCUMULATOR_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "conversion_error_field_driven", + docs=[{"_id": 1, "x": 1, "v": "notnum"}], + pipeline=[ + { + "$bucketAuto": { + "groupBy": "$x", + "buckets": 1, + "output": {"r": {"$sum": {"$add": ["$v", 1]}}}, + } + } + ], + error_code=TYPE_MISMATCH_ERROR, + msg="$bucketAuto should propagate a field-driven accumulator conversion error", + ), + StageTestCase( + "divide_by_zero_literal_driven", + docs=[{"_id": 1, "x": 1}], + pipeline=[ + { + "$bucketAuto": { + "groupBy": "$x", + "buckets": 1, + "output": {"r": {"$sum": {"$divide": [1, 0]}}}, + } + } + ], + error_code=BAD_VALUE_ERROR, + msg="$bucketAuto should propagate a literal-driven divide-by-zero error", + ), +] + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(BUCKET_AUTO_ACCUMULATOR_ERROR_TESTS)) +def test_bucketAuto_accumulator_errors(collection, test_case: StageTestCase): + """Test $bucketAuto accumulator error propagation.""" + populate_collection(collection, test_case) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/test_bucketAuto_arg_errors.py b/documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/test_bucketAuto_arg_errors.py new file mode 100644 index 000000000..205235e06 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/test_bucketAuto_arg_errors.py @@ -0,0 +1,244 @@ +"""Tests for $bucketAuto aggregation stage — argument and option validation errors.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, + populate_collection, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BUCKET_AUTO_ARG_NOT_OBJECT_ERROR, + BUCKET_AUTO_MISSING_REQUIRED_ERROR, + BUCKET_AUTO_UNRECOGNIZED_OPTION_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_ZERO, +) + +# Property [Argument Type Rejection]: $bucketAuto rejects all non-object BSON +# types as its argument. +BUCKET_AUTO_ARG_TYPE_TESTS: list[StageTestCase] = [ + StageTestCase( + "arg_type_string", + docs=[{"_id": 1}], + pipeline=[{"$bucketAuto": "hello"}], + error_code=BUCKET_AUTO_ARG_NOT_OBJECT_ERROR, + msg="$bucketAuto should reject string argument", + ), + StageTestCase( + "arg_type_int32", + docs=[{"_id": 1}], + pipeline=[{"$bucketAuto": 42}], + error_code=BUCKET_AUTO_ARG_NOT_OBJECT_ERROR, + msg="$bucketAuto should reject int32 argument", + ), + StageTestCase( + "arg_type_int64", + docs=[{"_id": 1}], + pipeline=[{"$bucketAuto": Int64(42)}], + error_code=BUCKET_AUTO_ARG_NOT_OBJECT_ERROR, + msg="$bucketAuto should reject int64 argument", + ), + StageTestCase( + "arg_type_double", + docs=[{"_id": 1}], + pipeline=[{"$bucketAuto": 3.14}], + error_code=BUCKET_AUTO_ARG_NOT_OBJECT_ERROR, + msg="$bucketAuto should reject double argument", + ), + StageTestCase( + "arg_type_decimal128", + docs=[{"_id": 1}], + pipeline=[{"$bucketAuto": DECIMAL128_ZERO}], + error_code=BUCKET_AUTO_ARG_NOT_OBJECT_ERROR, + msg="$bucketAuto should reject Decimal128 argument", + ), + StageTestCase( + "arg_type_bool", + docs=[{"_id": 1}], + pipeline=[{"$bucketAuto": True}], + error_code=BUCKET_AUTO_ARG_NOT_OBJECT_ERROR, + msg="$bucketAuto should reject boolean argument", + ), + StageTestCase( + "arg_type_null", + docs=[{"_id": 1}], + pipeline=[{"$bucketAuto": None}], + error_code=BUCKET_AUTO_ARG_NOT_OBJECT_ERROR, + msg="$bucketAuto should reject null argument", + ), + StageTestCase( + "arg_type_array", + docs=[{"_id": 1}], + pipeline=[{"$bucketAuto": [1, 2]}], + error_code=BUCKET_AUTO_ARG_NOT_OBJECT_ERROR, + msg="$bucketAuto should reject array argument", + ), + StageTestCase( + "arg_type_objectid", + docs=[{"_id": 1}], + pipeline=[{"$bucketAuto": ObjectId("000000000000000000000001")}], + error_code=BUCKET_AUTO_ARG_NOT_OBJECT_ERROR, + msg="$bucketAuto should reject ObjectId argument", + ), + StageTestCase( + "arg_type_datetime", + docs=[{"_id": 1}], + pipeline=[{"$bucketAuto": datetime(2024, 1, 1, tzinfo=timezone.utc)}], + error_code=BUCKET_AUTO_ARG_NOT_OBJECT_ERROR, + msg="$bucketAuto should reject datetime argument", + ), + StageTestCase( + "arg_type_timestamp", + docs=[{"_id": 1}], + pipeline=[{"$bucketAuto": Timestamp(1, 1)}], + error_code=BUCKET_AUTO_ARG_NOT_OBJECT_ERROR, + msg="$bucketAuto should reject Timestamp argument", + ), + StageTestCase( + "arg_type_binary", + docs=[{"_id": 1}], + pipeline=[{"$bucketAuto": Binary(b"hello")}], + error_code=BUCKET_AUTO_ARG_NOT_OBJECT_ERROR, + msg="$bucketAuto should reject Binary argument", + ), + StageTestCase( + "arg_type_regex", + docs=[{"_id": 1}], + pipeline=[{"$bucketAuto": Regex("abc")}], + error_code=BUCKET_AUTO_ARG_NOT_OBJECT_ERROR, + msg="$bucketAuto should reject Regex argument", + ), + StageTestCase( + "arg_type_code", + docs=[{"_id": 1}], + pipeline=[{"$bucketAuto": Code("function(){}")}], + error_code=BUCKET_AUTO_ARG_NOT_OBJECT_ERROR, + msg="$bucketAuto should reject Code argument", + ), + StageTestCase( + "arg_type_minkey", + docs=[{"_id": 1}], + pipeline=[{"$bucketAuto": MinKey()}], + error_code=BUCKET_AUTO_ARG_NOT_OBJECT_ERROR, + msg="$bucketAuto should reject MinKey argument", + ), + StageTestCase( + "arg_type_maxkey", + docs=[{"_id": 1}], + pipeline=[{"$bucketAuto": MaxKey()}], + error_code=BUCKET_AUTO_ARG_NOT_OBJECT_ERROR, + msg="$bucketAuto should reject MaxKey argument", + ), +] + +# Property [Unrecognized Option Rejection]: $bucketAuto rejects any key that is +# not one of the four recognized options (groupBy, buckets, output, +# granularity), including case-variant spellings. +BUCKET_AUTO_UNRECOGNIZED_OPTION_TESTS: list[StageTestCase] = [ + StageTestCase( + "extra_key", + docs=[{"_id": 1}], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2, "extra": 1}}], + error_code=BUCKET_AUTO_UNRECOGNIZED_OPTION_ERROR, + msg="$bucketAuto should reject unrecognized option 'extra'", + ), + StageTestCase( + "case_GroupBy", + docs=[{"_id": 1}], + pipeline=[{"$bucketAuto": {"GroupBy": "$x", "buckets": 2}}], + error_code=BUCKET_AUTO_UNRECOGNIZED_OPTION_ERROR, + msg="$bucketAuto should reject case-variant 'GroupBy'", + ), + StageTestCase( + "case_Buckets", + docs=[{"_id": 1}], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "Buckets": 2}}], + error_code=BUCKET_AUTO_UNRECOGNIZED_OPTION_ERROR, + msg="$bucketAuto should reject case-variant 'Buckets'", + ), + StageTestCase( + "case_Output", + docs=[{"_id": 1}], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2, "Output": {}}}], + error_code=BUCKET_AUTO_UNRECOGNIZED_OPTION_ERROR, + msg="$bucketAuto should reject case-variant 'Output'", + ), + StageTestCase( + "case_Granularity", + docs=[{"_id": 1}], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2, "Granularity": "R5"}}], + error_code=BUCKET_AUTO_UNRECOGNIZED_OPTION_ERROR, + msg="$bucketAuto should reject case-variant 'Granularity'", + ), +] + +# Property [Missing Required Fields]: $bucketAuto requires both 'groupBy' and +# 'buckets' to be specified. +BUCKET_AUTO_MISSING_REQUIRED_TESTS: list[StageTestCase] = [ + StageTestCase( + "missing_groupBy", + docs=[{"_id": 1}], + pipeline=[{"$bucketAuto": {"buckets": 2}}], + error_code=BUCKET_AUTO_MISSING_REQUIRED_ERROR, + msg="$bucketAuto should reject missing 'groupBy'", + ), + StageTestCase( + "missing_buckets", + docs=[{"_id": 1}], + pipeline=[{"$bucketAuto": {"groupBy": "$x"}}], + error_code=BUCKET_AUTO_MISSING_REQUIRED_ERROR, + msg="$bucketAuto should reject missing 'buckets'", + ), + StageTestCase( + "missing_both", + docs=[{"_id": 1}], + pipeline=[{"$bucketAuto": {}}], + error_code=BUCKET_AUTO_MISSING_REQUIRED_ERROR, + msg="$bucketAuto should reject empty object with no required fields", + ), +] + +BUCKET_AUTO_ARG_ERROR_TESTS = ( + BUCKET_AUTO_ARG_TYPE_TESTS + + BUCKET_AUTO_UNRECOGNIZED_OPTION_TESTS + + BUCKET_AUTO_MISSING_REQUIRED_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(BUCKET_AUTO_ARG_ERROR_TESTS)) +def test_bucketAuto_arg_errors(collection, test_case: StageTestCase): + """Test $bucketAuto argument and option validation errors.""" + populate_collection(collection, test_case) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/test_bucketAuto_boundaries.py b/documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/test_bucketAuto_boundaries.py new file mode 100644 index 000000000..ddbff8525 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/test_bucketAuto_boundaries.py @@ -0,0 +1,332 @@ +"""Tests for $bucketAuto aggregation stage — boundary semantics and type preservation.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Decimal128, + Int64, + ObjectId, +) + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, + populate_collection, +) +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.test_constants import ( + DECIMAL128_MAX, + DECIMAL128_MIN, +) + +# Property [Boundary Shape and Sharing]: each bucket _id has exactly min and +# max; min is inclusive, interior max is exclusive, the final max is inclusive, +# consecutive buckets share edges, and the outermost bounds equal the global +# min and max. +BUCKET_AUTO_BOUNDARY_SHAPE_TESTS: list[StageTestCase] = [ + StageTestCase( + "shared_edges_and_global_bounds", + docs=[{"_id": i, "x": i} for i in range(1, 7)], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 3}}], + expected=[ + {"_id": {"min": 1, "max": 3}, "count": 2}, + {"_id": {"min": 3, "max": 5}, "count": 2}, + {"_id": {"min": 5, "max": 6}, "count": 2}, + ], + msg=( + "$bucketAuto buckets should share edges, with the first min equal to the" + " global min and the last max equal to the global max" + ), + ), + StageTestCase( + "interior_max_exclusive", + docs=[ + {"_id": 1, "x": 1}, + {"_id": 2, "x": 2}, + {"_id": 3, "x": 3}, + {"_id": 4, "x": 3}, + {"_id": 5, "x": 4}, + {"_id": 6, "x": 5}, + ], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 3}}], + expected=[ + {"_id": {"min": 1, "max": 3}, "count": 2}, + {"_id": {"min": 3, "max": 4}, "count": 2}, + {"_id": {"min": 4, "max": 5}, "count": 2}, + ], + msg=( + "$bucketAuto interior max should be exclusive: a value equal to an interior" + " boundary lands in the higher bucket" + ), + ), +] + +# Property [Boundary Type Preservation]: the bucket _id min/max preserve the +# BSON type of the groupBy values. +BUCKET_AUTO_BOUNDARY_TYPE_TESTS: list[StageTestCase] = [ + StageTestCase( + "int32_boundaries", + docs=[ + {"_id": 1, "x": 5}, + {"_id": 2, "x": 15}, + {"_id": 3, "x": 25}, + {"_id": 4, "x": 35}, + ], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2}}], + expected=[ + {"_id": {"min": 5, "max": 25}, "count": 2}, + {"_id": {"min": 25, "max": 35}, "count": 2}, + ], + msg="$bucketAuto should produce int32-typed boundaries for int32 values", + ), + StageTestCase( + "int64_boundaries", + docs=[ + {"_id": 1, "x": Int64(5)}, + {"_id": 2, "x": Int64(15)}, + {"_id": 3, "x": Int64(25)}, + {"_id": 4, "x": Int64(35)}, + ], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2}}], + expected=[ + {"_id": {"min": Int64(5), "max": Int64(25)}, "count": 2}, + {"_id": {"min": Int64(25), "max": Int64(35)}, "count": 2}, + ], + msg="$bucketAuto should produce int64-typed boundaries for int64 values", + ), + StageTestCase( + "double_boundaries", + docs=[ + {"_id": 1, "x": 5.5}, + {"_id": 2, "x": 15.5}, + {"_id": 3, "x": 25.5}, + {"_id": 4, "x": 35.5}, + ], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2}}], + expected=[ + {"_id": {"min": 5.5, "max": 25.5}, "count": 2}, + {"_id": {"min": 25.5, "max": 35.5}, "count": 2}, + ], + msg="$bucketAuto should produce double-typed boundaries for double values", + ), + StageTestCase( + "decimal128_boundaries", + docs=[ + {"_id": 1, "x": Decimal128("5")}, + {"_id": 2, "x": Decimal128("15")}, + {"_id": 3, "x": Decimal128("25")}, + {"_id": 4, "x": Decimal128("35")}, + ], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2}}], + expected=[ + {"_id": {"min": Decimal128("5"), "max": Decimal128("25")}, "count": 2}, + {"_id": {"min": Decimal128("25"), "max": Decimal128("35")}, "count": 2}, + ], + msg="$bucketAuto should produce Decimal128-typed boundaries for Decimal128 values", + ), + StageTestCase( + "string_boundaries", + docs=[ + {"_id": 1, "x": "apple"}, + {"_id": 2, "x": "banana"}, + {"_id": 3, "x": "cherry"}, + {"_id": 4, "x": "date"}, + ], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2}}], + expected=[ + {"_id": {"min": "apple", "max": "cherry"}, "count": 2}, + {"_id": {"min": "cherry", "max": "date"}, "count": 2}, + ], + msg="$bucketAuto should order and bucket string values lexicographically", + ), + StageTestCase( + "date_boundaries", + docs=[ + {"_id": 1, "x": datetime(2020, 1, 1, tzinfo=timezone.utc)}, + {"_id": 2, "x": datetime(2021, 1, 1, tzinfo=timezone.utc)}, + {"_id": 3, "x": datetime(2022, 1, 1, tzinfo=timezone.utc)}, + {"_id": 4, "x": datetime(2023, 1, 1, tzinfo=timezone.utc)}, + ], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2}}], + expected=[ + { + "_id": { + "min": datetime(2020, 1, 1, tzinfo=timezone.utc), + "max": datetime(2022, 1, 1, tzinfo=timezone.utc), + }, + "count": 2, + }, + { + "_id": { + "min": datetime(2022, 1, 1, tzinfo=timezone.utc), + "max": datetime(2023, 1, 1, tzinfo=timezone.utc), + }, + "count": 2, + }, + ], + msg="$bucketAuto should order and bucket date values chronologically", + ), + StageTestCase( + "objectid_boundaries", + docs=[ + {"_id": 1, "x": ObjectId("000000000000000000000001")}, + {"_id": 2, "x": ObjectId("000000000000000000000002")}, + {"_id": 3, "x": ObjectId("000000000000000000000003")}, + {"_id": 4, "x": ObjectId("000000000000000000000004")}, + ], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2}}], + expected=[ + { + "_id": { + "min": ObjectId("000000000000000000000001"), + "max": ObjectId("000000000000000000000003"), + }, + "count": 2, + }, + { + "_id": { + "min": ObjectId("000000000000000000000003"), + "max": ObjectId("000000000000000000000004"), + }, + "count": 2, + }, + ], + msg="$bucketAuto should order and bucket ObjectId values", + ), + StageTestCase( + "bool_boundaries", + docs=[ + {"_id": 1, "x": False}, + {"_id": 2, "x": False}, + {"_id": 3, "x": True}, + {"_id": 4, "x": True}, + ], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2}}], + expected=[ + {"_id": {"min": False, "max": True}, "count": 2}, + {"_id": {"min": True, "max": True}, "count": 2}, + ], + msg="$bucketAuto should order boolean values with false < true", + ), + StageTestCase( + "decimal128_precision_preserved", + docs=[ + {"_id": 1, "x": Decimal128("1.5")}, + {"_id": 2, "x": Decimal128("2.5")}, + ], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 1}}], + expected=[ + {"_id": {"min": Decimal128("1.5"), "max": Decimal128("2.5")}, "count": 2}, + ], + msg="$bucketAuto should preserve Decimal128 precision in bucket boundaries", + ), + StageTestCase( + "decimal128_extreme_boundaries_preserved", + docs=[ + {"_id": 1, "x": DECIMAL128_MIN}, + {"_id": 2, "x": DECIMAL128_MAX}, + ], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 1}}], + expected=[ + {"_id": {"min": DECIMAL128_MIN, "max": DECIMAL128_MAX}, "count": 2}, + ], + msg="$bucketAuto should preserve extreme high-precision Decimal128 bucket boundaries", + ), +] + +# Property [Numeric Equivalence and Type Distinction]: numerically equivalent +# values across numeric types group together; booleans are distinct from +# numeric 0/1. +BUCKET_AUTO_NUMERIC_EQUIV_TESTS: list[StageTestCase] = [ + StageTestCase( + "numeric_equivalence_same_bucket", + docs=[ + {"_id": 1, "x": 1}, + {"_id": 2, "x": Int64(1)}, + {"_id": 3, "x": 1.0}, + {"_id": 4, "x": Decimal128("1")}, + ], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 1}}], + expected=[{"_id": {"min": 1, "max": Decimal128("1")}, "count": 4}], + msg="$bucketAuto should group numerically equivalent values across numeric types", + ), + StageTestCase( + "bool_distinct_from_numeric_zero", + docs=[{"_id": 1, "x": False}, {"_id": 2, "x": 0}], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2}}], + expected=[ + {"_id": {"min": 0, "max": False}, "count": 1}, + {"_id": {"min": False, "max": False}, "count": 1}, + ], + msg="$bucketAuto should treat boolean false as distinct from numeric 0", + ), +] + +# Property [Mixed Type Ordering]: mixed numeric subtypes are ordered +# numerically, and mixed BSON types use canonical BSON type ordering. +BUCKET_AUTO_MIXED_TYPE_TESTS: list[StageTestCase] = [ + StageTestCase( + "mixed_numeric_types_ordered", + docs=[ + {"_id": 1, "x": Decimal128("1")}, + {"_id": 2, "x": 5}, + {"_id": 3, "x": Int64(3)}, + {"_id": 4, "x": 2.5}, + ], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2}}], + expected=[ + {"_id": {"min": Decimal128("1"), "max": Int64(3)}, "count": 2}, + {"_id": {"min": Int64(3), "max": 5}, "count": 2}, + ], + msg="$bucketAuto should order mixed numeric subtypes numerically", + ), + StageTestCase( + "mixed_bson_types_canonical_order", + docs=[ + {"_id": 1, "x": None}, + {"_id": 2, "x": 5}, + {"_id": 3, "x": "str"}, + {"_id": 4, "x": 10}, + ], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 4}}], + expected=[ + {"_id": {"min": None, "max": 5}, "count": 1}, + {"_id": {"min": 5, "max": 10}, "count": 1}, + {"_id": {"min": 10, "max": "str"}, "count": 1}, + {"_id": {"min": "str", "max": "str"}, "count": 1}, + ], + msg="$bucketAuto should order mixed BSON types using canonical type ordering", + ), +] + +BUCKET_AUTO_BOUNDARY_TESTS = ( + BUCKET_AUTO_BOUNDARY_SHAPE_TESTS + + BUCKET_AUTO_BOUNDARY_TYPE_TESTS + + BUCKET_AUTO_NUMERIC_EQUIV_TESTS + + BUCKET_AUTO_MIXED_TYPE_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(BUCKET_AUTO_BOUNDARY_TESTS)) +def test_bucketAuto_boundaries(collection, test_case: StageTestCase): + """Test $bucketAuto boundary semantics and type preservation.""" + populate_collection(collection, test_case) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/test_bucketAuto_buckets_validation.py b/documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/test_bucketAuto_buckets_validation.py new file mode 100644 index 000000000..26da3940b --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/test_bucketAuto_buckets_validation.py @@ -0,0 +1,281 @@ +"""Tests for $bucketAuto aggregation stage — 'buckets' parameter validation.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, + populate_collection, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BUCKET_AUTO_BUCKETS_NOT_INT_ERROR, + BUCKET_AUTO_BUCKETS_NOT_NUMERIC_ERROR, + BUCKET_AUTO_BUCKETS_NOT_POSITIVE_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + FLOAT_INFINITY, + FLOAT_NAN, + INT32_OVERFLOW, +) + +_DOCS = [{"_id": i, "x": i} for i in range(1, 6)] + +# Property [Buckets Accepts Whole Numbers]: 'buckets' accepts any numeric type +# whose value is a whole number in 32-bit integer range. +BUCKET_AUTO_BUCKETS_ACCEPT_TESTS: list[StageTestCase] = [ + StageTestCase( + "buckets_int32", + docs=_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2}}], + expected=[ + {"_id": {"min": 1, "max": 4}, "count": 3}, + {"_id": {"min": 4, "max": 5}, "count": 2}, + ], + msg="$bucketAuto should accept an int32 'buckets' value", + ), + StageTestCase( + "buckets_int64_whole", + docs=_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": Int64(2)}}], + expected=[ + {"_id": {"min": 1, "max": 4}, "count": 3}, + {"_id": {"min": 4, "max": 5}, "count": 2}, + ], + msg="$bucketAuto should accept a whole-number int64 'buckets' value", + ), + StageTestCase( + "buckets_double_whole", + docs=_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2.0}}], + expected=[ + {"_id": {"min": 1, "max": 4}, "count": 3}, + {"_id": {"min": 4, "max": 5}, "count": 2}, + ], + msg="$bucketAuto should accept a whole-number double 'buckets' value", + ), + StageTestCase( + "buckets_decimal128_whole", + docs=_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": Decimal128("2")}}], + expected=[ + {"_id": {"min": 1, "max": 4}, "count": 3}, + {"_id": {"min": 4, "max": 5}, "count": 2}, + ], + msg="$bucketAuto should accept a whole-number Decimal128 'buckets' value", + ), + StageTestCase( + "buckets_one_single_bucket", + docs=_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 1}}], + expected=[{"_id": {"min": 1, "max": 5}, "count": 5}], + msg="$bucketAuto with buckets=1 should return a single bucket spanning all values", + ), +] + +# Property [Buckets Not Positive]: 'buckets' must be greater than 0. +BUCKET_AUTO_BUCKETS_NOT_POSITIVE_TESTS: list[StageTestCase] = [ + StageTestCase( + "buckets_zero", + docs=_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 0}}], + error_code=BUCKET_AUTO_BUCKETS_NOT_POSITIVE_ERROR, + msg="$bucketAuto should reject buckets=0", + ), + StageTestCase( + "buckets_negative", + docs=_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": -1}}], + error_code=BUCKET_AUTO_BUCKETS_NOT_POSITIVE_ERROR, + msg="$bucketAuto should reject a negative buckets value", + ), +] + +# Property [Buckets Not Integral]: 'buckets' must be representable as a 32-bit +# integer; fractional values, NaN, and overflowing values are rejected. +BUCKET_AUTO_BUCKETS_NOT_INT_TESTS: list[StageTestCase] = [ + StageTestCase( + "buckets_fractional_double", + docs=_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2.5}}], + error_code=BUCKET_AUTO_BUCKETS_NOT_INT_ERROR, + msg="$bucketAuto should reject a fractional double buckets value", + ), + StageTestCase( + "buckets_fractional_decimal128", + docs=_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": Decimal128("2.5")}}], + error_code=BUCKET_AUTO_BUCKETS_NOT_INT_ERROR, + msg="$bucketAuto should reject a fractional Decimal128 buckets value", + ), + StageTestCase( + "buckets_nan", + docs=_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": FLOAT_NAN}}], + error_code=BUCKET_AUTO_BUCKETS_NOT_INT_ERROR, + msg="$bucketAuto should reject a NaN buckets value", + ), + StageTestCase( + "buckets_overflow_32bit", + docs=_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": Int64(INT32_OVERFLOW)}}], + error_code=BUCKET_AUTO_BUCKETS_NOT_INT_ERROR, + msg="$bucketAuto should reject a buckets value exceeding 32-bit integer range", + ), + StageTestCase( + "buckets_infinity", + docs=_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": FLOAT_INFINITY}}], + error_code=BUCKET_AUTO_BUCKETS_NOT_INT_ERROR, + msg="$bucketAuto should reject an infinite buckets value", + ), +] + +# Property [Buckets Not Numeric]: 'buckets' must be a numeric value; all +# non-numeric BSON types are rejected. +BUCKET_AUTO_BUCKETS_NOT_NUMERIC_TESTS: list[StageTestCase] = [ + StageTestCase( + "buckets_string", + docs=_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": "2"}}], + error_code=BUCKET_AUTO_BUCKETS_NOT_NUMERIC_ERROR, + msg="$bucketAuto should reject a string buckets value", + ), + StageTestCase( + "buckets_bool", + docs=_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": True}}], + error_code=BUCKET_AUTO_BUCKETS_NOT_NUMERIC_ERROR, + msg="$bucketAuto should reject a bool buckets value", + ), + StageTestCase( + "buckets_null", + docs=_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": None}}], + error_code=BUCKET_AUTO_BUCKETS_NOT_NUMERIC_ERROR, + msg="$bucketAuto should reject a null buckets value", + ), + StageTestCase( + "buckets_array", + docs=_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": [2]}}], + error_code=BUCKET_AUTO_BUCKETS_NOT_NUMERIC_ERROR, + msg="$bucketAuto should reject an array buckets value", + ), + StageTestCase( + "buckets_object", + docs=_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": {"n": 2}}}], + error_code=BUCKET_AUTO_BUCKETS_NOT_NUMERIC_ERROR, + msg="$bucketAuto should reject an object buckets value", + ), + StageTestCase( + "buckets_objectid", + docs=_DOCS, + pipeline=[ + {"$bucketAuto": {"groupBy": "$x", "buckets": ObjectId("000000000000000000000001")}} + ], + error_code=BUCKET_AUTO_BUCKETS_NOT_NUMERIC_ERROR, + msg="$bucketAuto should reject an ObjectId buckets value", + ), + StageTestCase( + "buckets_datetime", + docs=_DOCS, + pipeline=[ + { + "$bucketAuto": { + "groupBy": "$x", + "buckets": datetime(2024, 1, 1, tzinfo=timezone.utc), + } + } + ], + error_code=BUCKET_AUTO_BUCKETS_NOT_NUMERIC_ERROR, + msg="$bucketAuto should reject a datetime buckets value", + ), + StageTestCase( + "buckets_timestamp", + docs=_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": Timestamp(1, 1)}}], + error_code=BUCKET_AUTO_BUCKETS_NOT_NUMERIC_ERROR, + msg="$bucketAuto should reject a Timestamp buckets value", + ), + StageTestCase( + "buckets_binary", + docs=_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": Binary(b"x")}}], + error_code=BUCKET_AUTO_BUCKETS_NOT_NUMERIC_ERROR, + msg="$bucketAuto should reject a Binary buckets value", + ), + StageTestCase( + "buckets_regex", + docs=_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": Regex("abc")}}], + error_code=BUCKET_AUTO_BUCKETS_NOT_NUMERIC_ERROR, + msg="$bucketAuto should reject a Regex buckets value", + ), + StageTestCase( + "buckets_code", + docs=_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": Code("function(){}")}}], + error_code=BUCKET_AUTO_BUCKETS_NOT_NUMERIC_ERROR, + msg="$bucketAuto should reject a Code buckets value", + ), + StageTestCase( + "buckets_minkey", + docs=_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": MinKey()}}], + error_code=BUCKET_AUTO_BUCKETS_NOT_NUMERIC_ERROR, + msg="$bucketAuto should reject a MinKey buckets value", + ), + StageTestCase( + "buckets_maxkey", + docs=_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": MaxKey()}}], + error_code=BUCKET_AUTO_BUCKETS_NOT_NUMERIC_ERROR, + msg="$bucketAuto should reject a MaxKey buckets value", + ), +] + +BUCKET_AUTO_BUCKETS_TESTS = ( + BUCKET_AUTO_BUCKETS_ACCEPT_TESTS + + BUCKET_AUTO_BUCKETS_NOT_POSITIVE_TESTS + + BUCKET_AUTO_BUCKETS_NOT_INT_TESTS + + BUCKET_AUTO_BUCKETS_NOT_NUMERIC_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(BUCKET_AUTO_BUCKETS_TESTS)) +def test_bucketAuto_buckets_validation(collection, test_case: StageTestCase): + """Test $bucketAuto 'buckets' parameter validation.""" + populate_collection(collection, test_case) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/test_bucketAuto_core_semantics.py b/documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/test_bucketAuto_core_semantics.py new file mode 100644 index 000000000..47b173f19 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/test_bucketAuto_core_semantics.py @@ -0,0 +1,128 @@ +"""Tests for $bucketAuto aggregation stage — core distribution semantics.""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, + populate_collection, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Empty Input]: $bucketAuto over an empty or non-existent collection +# returns no buckets without error. +BUCKET_AUTO_EMPTY_INPUT_TESTS: list[StageTestCase] = [ + StageTestCase( + "empty_collection", + docs=[], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2}}], + expected=[], + msg="$bucketAuto over an empty collection should return no buckets", + ), + StageTestCase( + "nonexistent_collection", + docs=None, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2}}], + expected=[], + msg="$bucketAuto over a non-existent collection should return no buckets", + ), +] + +# Property [Bucket Count Bounds]: the number of buckets never exceeds the +# number of distinct groupBy values or the document count. +BUCKET_AUTO_COUNT_BOUNDS_TESTS: list[StageTestCase] = [ + StageTestCase( + "single_document", + docs=[{"_id": 1, "x": 5}], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 3}}], + expected=[{"_id": {"min": 5, "max": 5}, "count": 1}], + msg="$bucketAuto with a single document should return exactly one bucket", + ), + StageTestCase( + "all_identical_values", + docs=[{"_id": 1, "x": 5}, {"_id": 2, "x": 5}, {"_id": 3, "x": 5}], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2}}], + expected=[{"_id": {"min": 5, "max": 5}, "count": 3}], + msg="$bucketAuto with all identical values should return one bucket", + ), + StageTestCase( + "fewer_unique_than_buckets", + docs=[ + {"_id": 1, "x": 1}, + {"_id": 2, "x": 1}, + {"_id": 3, "x": 2}, + {"_id": 4, "x": 2}, + ], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 4}}], + expected=[ + {"_id": {"min": 1, "max": 2}, "count": 2}, + {"_id": {"min": 2, "max": 2}, "count": 2}, + ], + msg="$bucketAuto should return fewer buckets than requested when unique values are fewer", + ), + StageTestCase( + "fewer_documents_than_buckets", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 2}], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 5}}], + expected=[ + {"_id": {"min": 1, "max": 2}, "count": 1}, + {"_id": {"min": 2, "max": 2}, "count": 1}, + ], + msg="$bucketAuto should return at most one bucket per document", + ), +] + +# Property [Document Distribution]: documents are distributed as evenly as +# possible; when they do not divide evenly, earlier buckets absorb the extras. +BUCKET_AUTO_DISTRIBUTION_TESTS: list[StageTestCase] = [ + StageTestCase( + "even_distribution", + docs=[{"_id": i, "x": i} for i in range(1, 9)], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 4}}], + expected=[ + {"_id": {"min": 1, "max": 3}, "count": 2}, + {"_id": {"min": 3, "max": 5}, "count": 2}, + {"_id": {"min": 5, "max": 7}, "count": 2}, + {"_id": {"min": 7, "max": 8}, "count": 2}, + ], + msg="$bucketAuto should distribute documents evenly when count divides evenly", + ), + StageTestCase( + "uneven_distribution_earlier_absorbs", + docs=[{"_id": i, "x": i} for i in range(1, 8)], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2}}], + expected=[ + {"_id": {"min": 1, "max": 5}, "count": 4}, + {"_id": {"min": 5, "max": 7}, "count": 3}, + ], + msg="$bucketAuto should give extra documents to earlier buckets when uneven", + ), +] + +BUCKET_AUTO_CORE_TESTS = ( + BUCKET_AUTO_EMPTY_INPUT_TESTS + BUCKET_AUTO_COUNT_BOUNDS_TESTS + BUCKET_AUTO_DISTRIBUTION_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(BUCKET_AUTO_CORE_TESTS)) +def test_bucketAuto_core_semantics(collection, test_case: StageTestCase): + """Test $bucketAuto core distribution semantics.""" + coll = populate_collection(collection, test_case) + result = execute_command( + coll, + { + "aggregate": coll.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/test_bucketAuto_granularity.py b/documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/test_bucketAuto_granularity.py new file mode 100644 index 000000000..8e13c979e --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/test_bucketAuto_granularity.py @@ -0,0 +1,243 @@ +"""Tests for $bucketAuto aggregation stage — 'granularity' option.""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.stages.bucketAuto.utils.bucketAuto_common import ( # noqa: E501 + GRANULARITY_VALUES, +) +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, + populate_collection, +) +from documentdb_tests.framework.assertions import assertNotError, assertResult +from documentdb_tests.framework.error_codes import ( + BUCKET_AUTO_GRANULARITY_NAN_ERROR, + BUCKET_AUTO_GRANULARITY_NEGATIVE_ERROR, + BUCKET_AUTO_GRANULARITY_NON_NUMERIC_ERROR, + BUCKET_AUTO_GRANULARITY_NOT_STRING_ERROR, + BUCKET_AUTO_GRANULARITY_UNKNOWN_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, +) + +_GRAN_DOCS = [{"_id": 1, "x": 1}, {"_id": 2, "x": 10}, {"_id": 3, "x": 100}, {"_id": 4, "x": 1000}] + +# Property [Granularity Value Acceptance]: each documented preferred-number +# series string is accepted as a 'granularity' value. +BUCKET_AUTO_GRANULARITY_ACCEPT_TESTS: list[StageTestCase] = [ + StageTestCase( + f"granularity_accept_{series}", + docs=_GRAN_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2, "granularity": series}}], + msg=f"$bucketAuto should accept granularity '{series}'", + ) + for series in GRANULARITY_VALUES +] + +# Property [Granularity Rounds Boundaries]: granularity rounds bucket +# boundaries to the preferred-number series for a known dataset. +BUCKET_AUTO_GRANULARITY_ROUNDING_TESTS: list[StageTestCase] = [ + StageTestCase( + "granularity_R5_rounds_boundaries", + docs=_GRAN_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 3, "granularity": "R5"}}], + expected=[ + {"_id": {"min": 0.63, "max": 1.6}, "count": 1}, + {"_id": {"min": 1.6, "max": 16.0}, "count": 1}, + {"_id": {"min": 16.0, "max": 1600.0}, "count": 2}, + ], + msg="$bucketAuto granularity 'R5' should round boundaries to the R5 series", + ), + StageTestCase( + "granularity_powersof2_rounds_boundaries", + docs=_GRAN_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 3, "granularity": "POWERSOF2"}}], + expected=[ + {"_id": {"min": 0.5, "max": 2}, "count": 1}, + {"_id": {"min": 2, "max": 16}, "count": 1}, + {"_id": {"min": 16, "max": 1024}, "count": 2}, + ], + msg="$bucketAuto granularity 'POWERSOF2' should round boundaries to powers of two", + ), + StageTestCase( + "granularity_1_2_5_rounds_boundaries", + docs=_GRAN_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 3, "granularity": "1-2-5"}}], + expected=[ + {"_id": {"min": 0.5, "max": 2.0}, "count": 1}, + {"_id": {"min": 2.0, "max": 20.0}, "count": 1}, + {"_id": {"min": 20.0, "max": 2000.0}, "count": 2}, + ], + msg="$bucketAuto granularity '1-2-5' should round boundaries to the 1-2-5 series", + ), + StageTestCase( + "granularity_E6_rounds_boundaries", + docs=_GRAN_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 3, "granularity": "E6"}}], + expected=[ + {"_id": {"min": 0.68, "max": 1.5}, "count": 1}, + {"_id": {"min": 1.5, "max": 15.0}, "count": 1}, + {"_id": {"min": 15.0, "max": 1500.0}, "count": 2}, + ], + msg="$bucketAuto granularity 'E6' should round boundaries to the E6 series", + ), + StageTestCase( + "granularity_fewer_buckets_when_coarse", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 2}], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 10, "granularity": "R5"}}], + expected=[ + {"_id": {"min": 0.63, "max": 1.6}, "count": 1}, + {"_id": {"min": 1.6, "max": 2.5}, "count": 1}, + ], + msg="$bucketAuto granularity should produce fewer buckets than requested when coarse", + ), +] + +# Property [Granularity Value Rejection]: unknown and empty-string granularity +# values are rejected. +BUCKET_AUTO_GRANULARITY_VALUE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "granularity_unknown_string", + docs=_GRAN_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2, "granularity": "BOGUS"}}], + error_code=BUCKET_AUTO_GRANULARITY_UNKNOWN_ERROR, + msg="$bucketAuto should reject an unrecognized granularity string", + ), + StageTestCase( + "granularity_empty_string", + docs=_GRAN_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2, "granularity": ""}}], + error_code=BUCKET_AUTO_GRANULARITY_UNKNOWN_ERROR, + msg="$bucketAuto should reject an empty granularity string", + ), +] + +# Property [Granularity Type Rejection]: 'granularity' must be a string; all +# non-string BSON types are rejected. +BUCKET_AUTO_GRANULARITY_TYPE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "granularity_int", + docs=_GRAN_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2, "granularity": 5}}], + error_code=BUCKET_AUTO_GRANULARITY_NOT_STRING_ERROR, + msg="$bucketAuto should reject an int granularity value", + ), + StageTestCase( + "granularity_double", + docs=_GRAN_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2, "granularity": 1.0}}], + error_code=BUCKET_AUTO_GRANULARITY_NOT_STRING_ERROR, + msg="$bucketAuto should reject a double granularity value", + ), + StageTestCase( + "granularity_bool", + docs=_GRAN_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2, "granularity": True}}], + error_code=BUCKET_AUTO_GRANULARITY_NOT_STRING_ERROR, + msg="$bucketAuto should reject a bool granularity value", + ), + StageTestCase( + "granularity_null", + docs=_GRAN_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2, "granularity": None}}], + error_code=BUCKET_AUTO_GRANULARITY_NOT_STRING_ERROR, + msg="$bucketAuto should reject a null granularity value", + ), + StageTestCase( + "granularity_array", + docs=_GRAN_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2, "granularity": ["R5"]}}], + error_code=BUCKET_AUTO_GRANULARITY_NOT_STRING_ERROR, + msg="$bucketAuto should reject an array granularity value", + ), + StageTestCase( + "granularity_object", + docs=_GRAN_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2, "granularity": {"s": "R5"}}}], + error_code=BUCKET_AUTO_GRANULARITY_NOT_STRING_ERROR, + msg="$bucketAuto should reject an object granularity value", + ), +] + +# Property [Granularity Numeric-Only Constraint]: granularity requires all +# groupBy values to be non-negative numbers with none NaN. +BUCKET_AUTO_GRANULARITY_NUMERIC_CONSTRAINT_TESTS: list[StageTestCase] = [ + StageTestCase( + "granularity_non_numeric_groupBy", + docs=[{"_id": 1, "x": "a"}, {"_id": 2, "x": "b"}], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2, "granularity": "R5"}}], + error_code=BUCKET_AUTO_GRANULARITY_NON_NUMERIC_ERROR, + msg="$bucketAuto granularity should be rejected when a groupBy value is non-numeric", + ), + StageTestCase( + "granularity_nan_groupBy", + docs=[{"_id": 1, "x": FLOAT_NAN}, {"_id": 2, "x": FLOAT_NAN}], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2, "granularity": "R5"}}], + error_code=BUCKET_AUTO_GRANULARITY_NAN_ERROR, + msg="$bucketAuto granularity should be rejected when a groupBy value is NaN", + ), + StageTestCase( + "granularity_negative_groupBy", + docs=[{"_id": 1, "x": -5}, {"_id": 2, "x": -1}], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2, "granularity": "R5"}}], + error_code=BUCKET_AUTO_GRANULARITY_NEGATIVE_ERROR, + msg="$bucketAuto granularity should be rejected when a groupBy value is negative", + ), + StageTestCase( + "granularity_negative_infinity_groupBy", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": FLOAT_NEGATIVE_INFINITY}], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 1, "granularity": "POWERSOF2"}}], + error_code=BUCKET_AUTO_GRANULARITY_NEGATIVE_ERROR, + msg="$bucketAuto granularity rejects -Infinity via the negative-value check", + ), +] + +BUCKET_AUTO_GRANULARITY_TESTS = ( + BUCKET_AUTO_GRANULARITY_ROUNDING_TESTS + + BUCKET_AUTO_GRANULARITY_VALUE_ERROR_TESTS + + BUCKET_AUTO_GRANULARITY_TYPE_ERROR_TESTS + + BUCKET_AUTO_GRANULARITY_NUMERIC_CONSTRAINT_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(BUCKET_AUTO_GRANULARITY_ACCEPT_TESTS)) +def test_bucketAuto_granularity_accepted(collection, test_case: StageTestCase): + """Test that $bucketAuto accepts each documented granularity series value.""" + populate_collection(collection, test_case) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertNotError(result, msg=test_case.msg) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(BUCKET_AUTO_GRANULARITY_TESTS)) +def test_bucketAuto_granularity(collection, test_case: StageTestCase): + """Test $bucketAuto 'granularity' rounding behavior and validation.""" + populate_collection(collection, test_case) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/test_bucketAuto_groupby.py b/documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/test_bucketAuto_groupby.py new file mode 100644 index 000000000..511581eda --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/test_bucketAuto_groupby.py @@ -0,0 +1,135 @@ +"""Tests for $bucketAuto aggregation stage — 'groupBy' expression types and null/missing.""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, + populate_collection, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BUCKET_AUTO_GROUPBY_NOT_EXPRESSION_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [GroupBy Expression Types]: 'groupBy' accepts a $-prefixed field +# path, a dotted nested path, an expression operator, or a $literal constant. +BUCKET_AUTO_GROUPBY_EXPR_TESTS: list[StageTestCase] = [ + StageTestCase( + "groupBy_field_path", + docs=[ + {"_id": 1, "x": 5}, + {"_id": 2, "x": 15}, + {"_id": 3, "x": 25}, + {"_id": 4, "x": 35}, + ], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2}}], + expected=[ + {"_id": {"min": 5, "max": 25}, "count": 2}, + {"_id": {"min": 25, "max": 35}, "count": 2}, + ], + msg="$bucketAuto should group by a $-prefixed field path", + ), + StageTestCase( + "groupBy_dotted_path", + docs=[ + {"_id": 1, "a": {"b": 5}}, + {"_id": 2, "a": {"b": 15}}, + ], + pipeline=[{"$bucketAuto": {"groupBy": "$a.b", "buckets": 2}}], + expected=[ + {"_id": {"min": 5, "max": 15}, "count": 1}, + {"_id": {"min": 15, "max": 15}, "count": 1}, + ], + msg="$bucketAuto should group by a dotted nested field path", + ), + StageTestCase( + "groupBy_expression_operator", + docs=[ + {"_id": 1, "a": 1, "b": 2}, + {"_id": 2, "a": 3, "b": 4}, + ], + pipeline=[{"$bucketAuto": {"groupBy": {"$add": ["$a", "$b"]}, "buckets": 2}}], + expected=[ + {"_id": {"min": 3, "max": 7}, "count": 1}, + {"_id": {"min": 7, "max": 7}, "count": 1}, + ], + msg="$bucketAuto should group by an expression operator over fields", + ), + StageTestCase( + "groupBy_literal_constant", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 2}], + pipeline=[{"$bucketAuto": {"groupBy": {"$literal": "c"}, "buckets": 2}}], + expected=[{"_id": {"min": "c", "max": "c"}, "count": 2}], + msg="$bucketAuto with a $literal constant groupBy should produce a single bucket", + ), +] + +# Property [Null and Missing Grouping]: documents whose groupBy resolves to +# null or a missing field are grouped together into a null-valued bucket. +BUCKET_AUTO_GROUPBY_NULL_TESTS: list[StageTestCase] = [ + StageTestCase( + "groupBy_null_and_missing_grouped", + docs=[ + {"_id": 1, "x": None}, + {"_id": 2}, + {"_id": 3, "x": 5}, + {"_id": 4, "x": 10}, + ], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2}}], + expected=[ + {"_id": {"min": None, "max": 5}, "count": 2}, + {"_id": {"min": 5, "max": 10}, "count": 2}, + ], + msg="$bucketAuto should group null and missing groupBy values together", + ), + StageTestCase( + "groupBy_all_missing", + docs=[{"_id": 1}, {"_id": 2}, {"_id": 3}], + pipeline=[{"$bucketAuto": {"groupBy": "$nope", "buckets": 2}}], + expected=[{"_id": {"min": None, "max": None}, "count": 3}], + msg="$bucketAuto should place all documents with a missing groupBy field in one bucket", + ), +] + +# Property [GroupBy Expression Rejection]: a non-$-prefixed constant string is +# rejected because groupBy must be a $-prefixed path or an expression. +BUCKET_AUTO_GROUPBY_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "groupBy_non_dollar_string", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 2}], + pipeline=[{"$bucketAuto": {"groupBy": "literalval", "buckets": 2}}], + error_code=BUCKET_AUTO_GROUPBY_NOT_EXPRESSION_ERROR, + msg="$bucketAuto should reject a non-$-prefixed string groupBy", + ), +] + +BUCKET_AUTO_GROUPBY_TESTS = ( + BUCKET_AUTO_GROUPBY_EXPR_TESTS + + BUCKET_AUTO_GROUPBY_NULL_TESTS + + BUCKET_AUTO_GROUPBY_ERROR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(BUCKET_AUTO_GROUPBY_TESTS)) +def test_bucketAuto_groupby(collection, test_case: StageTestCase): + """Test $bucketAuto 'groupBy' expression types and null/missing handling.""" + populate_collection(collection, test_case) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/test_bucketAuto_output.py b/documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/test_bucketAuto_output.py new file mode 100644 index 000000000..f217e0545 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/test_bucketAuto_output.py @@ -0,0 +1,282 @@ +"""Tests for $bucketAuto aggregation stage — output specification behavior.""" + +from __future__ import annotations + +import pytest +from bson.son import SON + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, + populate_collection, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Implicit Count Field]: when output is omitted each bucket includes +# a count field; specifying output replaces the implicit count with the named +# fields; an empty output document still yields the implicit count. +BUCKET_AUTO_IMPLICIT_COUNT_TESTS: list[StageTestCase] = [ + StageTestCase( + "output_omitted_includes_count", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 5}], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 1}}], + expected=[{"_id": {"min": 1, "max": 5}, "count": 2}], + msg="$bucketAuto without output should include implicit count field", + ), + StageTestCase( + "output_specified_replaces_count", + docs=[{"_id": 1, "x": 1, "v": 10}, {"_id": 2, "x": 5, "v": 20}], + pipeline=[ + { + "$bucketAuto": { + "groupBy": "$x", + "buckets": 1, + "output": {"total": {"$sum": "$v"}}, + } + } + ], + expected=[{"_id": {"min": 1, "max": 5}, "total": 30}], + msg="$bucketAuto with output specified should not include implicit count field", + ), + StageTestCase( + "output_explicit_count", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 5}], + pipeline=[ + { + "$bucketAuto": { + "groupBy": "$x", + "buckets": 1, + "output": {"c": {"$sum": 1}}, + } + } + ], + expected=[{"_id": {"min": 1, "max": 5}, "c": 2}], + msg="$bucketAuto output can re-add count explicitly via {$sum: 1}", + ), + StageTestCase( + "empty_output_yields_count", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 5}], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 1, "output": {}}}], + expected=[{"_id": {"min": 1, "max": 5}, "count": 2}], + msg="$bucketAuto with an empty output document should still yield the implicit count", + ), +] + +# Property [Multiple Accumulators]: multiple accumulator operators can be used +# simultaneously in the output specification. +BUCKET_AUTO_MULTIPLE_ACCUMULATORS_TESTS: list[StageTestCase] = [ + StageTestCase( + "multiple_accumulators_in_output", + docs=[ + {"_id": 1, "x": 1, "v": 10}, + {"_id": 2, "x": 1, "v": 20}, + {"_id": 3, "x": 1, "v": 30}, + ], + pipeline=[ + { + "$bucketAuto": { + "groupBy": "$x", + "buckets": 1, + "output": { + "total": {"$sum": "$v"}, + "avg": {"$avg": "$v"}, + "items": {"$push": "$v"}, + }, + } + } + ], + expected=[{"_id": {"min": 1, "max": 1}, "total": 60, "avg": 20.0, "items": [10, 20, 30]}], + msg="$bucketAuto output should accept multiple accumulators simultaneously", + ), +] + +# Property [Accumulator Input Field References]: accumulators reference input +# document fields, not sibling accumulator output fields. +BUCKET_AUTO_ACCUMULATOR_INPUT_REF_TESTS: list[StageTestCase] = [ + StageTestCase( + "accumulator_references_input_not_sibling", + docs=[{"_id": 1, "x": 1, "v": 10}, {"_id": 2, "x": 1, "v": 20}], + pipeline=[ + { + "$bucketAuto": { + "groupBy": "$x", + "buckets": 1, + "output": { + "total": {"$sum": "$v"}, + "ref_sibling": {"$sum": "$total"}, + }, + } + } + ], + expected=[{"_id": {"min": 1, "max": 1}, "total": 30, "ref_sibling": 0}], + msg=( + "$bucketAuto accumulators should reference input document" + " fields, not sibling output fields" + ), + ), +] + +# Property [Nested Expressions in Accumulators]: nested expressions work within +# accumulator arguments. +BUCKET_AUTO_NESTED_EXPR_TESTS: list[StageTestCase] = [ + StageTestCase( + "nested_expression_in_accumulator", + docs=[{"_id": 1, "x": 1, "v": 10}, {"_id": 2, "x": 1, "v": 20}], + pipeline=[ + { + "$bucketAuto": { + "groupBy": "$x", + "buckets": 1, + "output": {"result": {"$sum": {"$add": ["$v", 1]}}}, + } + } + ], + expected=[{"_id": {"min": 1, "max": 1}, "result": 32}], + msg="$bucketAuto should support nested expressions within accumulators", + ), +] + +# Property [Push System Variables]: $push with $$ROOT or $$CURRENT returns full +# input documents; $push with $$REMOVE produces empty arrays. +BUCKET_AUTO_PUSH_SYSTEM_VAR_TESTS: list[StageTestCase] = [ + StageTestCase( + "push_root_returns_full_docs", + docs=[{"_id": 1, "x": 1, "v": 10}], + pipeline=[ + { + "$bucketAuto": { + "groupBy": "$x", + "buckets": 1, + "output": {"docs": {"$push": "$$ROOT"}}, + } + } + ], + expected=[{"_id": {"min": 1, "max": 1}, "docs": [{"_id": 1, "x": 1, "v": 10}]}], + msg="$bucketAuto $push with $$ROOT should return full input documents", + ), + StageTestCase( + "push_current_returns_full_docs", + docs=[{"_id": 1, "x": 1, "v": 10}], + pipeline=[ + { + "$bucketAuto": { + "groupBy": "$x", + "buckets": 1, + "output": {"docs": {"$push": "$$CURRENT"}}, + } + } + ], + expected=[{"_id": {"min": 1, "max": 1}, "docs": [{"_id": 1, "x": 1, "v": 10}]}], + msg="$bucketAuto $push with $$CURRENT should return full input documents", + ), + StageTestCase( + "push_remove_produces_empty_array", + docs=[{"_id": 1, "x": 1}], + pipeline=[ + { + "$bucketAuto": { + "groupBy": "$x", + "buckets": 1, + "output": {"docs": {"$push": "$$REMOVE"}}, + } + } + ], + expected=[{"_id": {"min": 1, "max": 1}, "docs": []}], + msg="$bucketAuto $push with $$REMOVE should produce empty arrays", + ), +] + +# Property [Output Field Name Acceptance]: empty string, Unicode, emoji, +# spaces, and long field names are accepted as output field names. +BUCKET_AUTO_OUTPUT_FIELD_NAME_TESTS: list[StageTestCase] = [ + StageTestCase( + "special_output_field_names", + docs=[{"_id": 1, "x": 1}], + pipeline=[ + { + "$bucketAuto": { + "groupBy": "$x", + "buckets": 1, + "output": { + "": {"$sum": 1}, + "é": {"$sum": 1}, + "\U0001f389": {"$sum": 1}, + " ": {"$sum": 1}, + "a" * 1_000: {"$sum": 1}, + }, + } + } + ], + expected=[ + { + "_id": {"min": 1, "max": 1}, + "": 1, + "é": 1, + "\U0001f389": 1, + " ": 1, + "a" * 1_000: 1, + } + ], + msg=( + "$bucketAuto should accept empty string, Unicode, emoji," + " spaces, and long field names as output field names" + ), + ), +] + +# Property [Duplicate Output Field Names]: duplicate output field names resolve +# to the last definition. +BUCKET_AUTO_DUPLICATE_FIELD_NAME_TESTS: list[StageTestCase] = [ + StageTestCase( + "duplicate_output_field_last_wins", + docs=[{"_id": 1, "x": 1, "v": 10}], + pipeline=[ + { + "$bucketAuto": { + "groupBy": "$x", + "buckets": 1, + "output": SON( + [ + ("total", {"$sum": "$v"}), + ("total", {"$sum": 1}), + ] + ), + } + } + ], + expected=[{"_id": {"min": 1, "max": 1}, "total": 1}], + msg="$bucketAuto duplicate output field names should resolve to the last definition", + ), +] + +BUCKET_AUTO_OUTPUT_TESTS = ( + BUCKET_AUTO_IMPLICIT_COUNT_TESTS + + BUCKET_AUTO_MULTIPLE_ACCUMULATORS_TESTS + + BUCKET_AUTO_ACCUMULATOR_INPUT_REF_TESTS + + BUCKET_AUTO_NESTED_EXPR_TESTS + + BUCKET_AUTO_PUSH_SYSTEM_VAR_TESTS + + BUCKET_AUTO_OUTPUT_FIELD_NAME_TESTS + + BUCKET_AUTO_DUPLICATE_FIELD_NAME_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(BUCKET_AUTO_OUTPUT_TESTS)) +def test_bucketAuto_output(collection, test_case: StageTestCase): + """Test $bucketAuto output specification behavior.""" + populate_collection(collection, test_case) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/test_bucketAuto_output_errors.py b/documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/test_bucketAuto_output_errors.py new file mode 100644 index 000000000..54f52da31 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/test_bucketAuto_output_errors.py @@ -0,0 +1,227 @@ +"""Tests for $bucketAuto aggregation stage — output field validation errors.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, + populate_collection, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BUCKET_AUTO_OUTPUT_NOT_OBJECT_ERROR, + BUCKET_OUTPUT_DOLLAR_PREFIX_ERROR, + BUCKET_OUTPUT_DOT_ERROR, + BUCKET_OUTPUT_NOT_ACCUMULATOR_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_ZERO, +) + + +def _out(value): + return [{"$bucketAuto": {"groupBy": "$x", "buckets": 2, "output": value}}] + + +# Property [Output Type Rejection]: the 'output' field must be an object; all +# non-object types are rejected. +BUCKET_AUTO_OUTPUT_TYPE_TESTS: list[StageTestCase] = [ + StageTestCase( + "output_string", + docs=[{"_id": 1}], + pipeline=_out("bad"), + error_code=BUCKET_AUTO_OUTPUT_NOT_OBJECT_ERROR, + msg="$bucketAuto should reject string output", + ), + StageTestCase( + "output_int32", + docs=[{"_id": 1}], + pipeline=_out(42), + error_code=BUCKET_AUTO_OUTPUT_NOT_OBJECT_ERROR, + msg="$bucketAuto should reject int32 output", + ), + StageTestCase( + "output_int64", + docs=[{"_id": 1}], + pipeline=_out(Int64(42)), + error_code=BUCKET_AUTO_OUTPUT_NOT_OBJECT_ERROR, + msg="$bucketAuto should reject int64 output", + ), + StageTestCase( + "output_double", + docs=[{"_id": 1}], + pipeline=_out(3.14), + error_code=BUCKET_AUTO_OUTPUT_NOT_OBJECT_ERROR, + msg="$bucketAuto should reject double output", + ), + StageTestCase( + "output_decimal128", + docs=[{"_id": 1}], + pipeline=_out(DECIMAL128_ZERO), + error_code=BUCKET_AUTO_OUTPUT_NOT_OBJECT_ERROR, + msg="$bucketAuto should reject Decimal128 output", + ), + StageTestCase( + "output_bool", + docs=[{"_id": 1}], + pipeline=_out(True), + error_code=BUCKET_AUTO_OUTPUT_NOT_OBJECT_ERROR, + msg="$bucketAuto should reject bool output", + ), + StageTestCase( + "output_null", + docs=[{"_id": 1}], + pipeline=_out(None), + error_code=BUCKET_AUTO_OUTPUT_NOT_OBJECT_ERROR, + msg="$bucketAuto should reject null output", + ), + StageTestCase( + "output_array", + docs=[{"_id": 1}], + pipeline=_out([1]), + error_code=BUCKET_AUTO_OUTPUT_NOT_OBJECT_ERROR, + msg="$bucketAuto should reject array output", + ), + StageTestCase( + "output_objectid", + docs=[{"_id": 1}], + pipeline=_out(ObjectId("000000000000000000000001")), + error_code=BUCKET_AUTO_OUTPUT_NOT_OBJECT_ERROR, + msg="$bucketAuto should reject ObjectId output", + ), + StageTestCase( + "output_datetime", + docs=[{"_id": 1}], + pipeline=_out(datetime(2024, 1, 1, tzinfo=timezone.utc)), + error_code=BUCKET_AUTO_OUTPUT_NOT_OBJECT_ERROR, + msg="$bucketAuto should reject datetime output", + ), + StageTestCase( + "output_timestamp", + docs=[{"_id": 1}], + pipeline=_out(Timestamp(1, 1)), + error_code=BUCKET_AUTO_OUTPUT_NOT_OBJECT_ERROR, + msg="$bucketAuto should reject Timestamp output", + ), + StageTestCase( + "output_binary", + docs=[{"_id": 1}], + pipeline=_out(Binary(b"hi")), + error_code=BUCKET_AUTO_OUTPUT_NOT_OBJECT_ERROR, + msg="$bucketAuto should reject Binary output", + ), + StageTestCase( + "output_regex", + docs=[{"_id": 1}], + pipeline=_out(Regex("abc")), + error_code=BUCKET_AUTO_OUTPUT_NOT_OBJECT_ERROR, + msg="$bucketAuto should reject Regex output", + ), + StageTestCase( + "output_code", + docs=[{"_id": 1}], + pipeline=_out(Code("function(){}")), + error_code=BUCKET_AUTO_OUTPUT_NOT_OBJECT_ERROR, + msg="$bucketAuto should reject Code output", + ), + StageTestCase( + "output_minkey", + docs=[{"_id": 1}], + pipeline=_out(MinKey()), + error_code=BUCKET_AUTO_OUTPUT_NOT_OBJECT_ERROR, + msg="$bucketAuto should reject MinKey output", + ), + StageTestCase( + "output_maxkey", + docs=[{"_id": 1}], + pipeline=_out(MaxKey()), + error_code=BUCKET_AUTO_OUTPUT_NOT_OBJECT_ERROR, + msg="$bucketAuto should reject MaxKey output", + ), +] + +# Property [Output Field Dollar Prefix Rejection]: output field names starting +# with $ are rejected. +BUCKET_AUTO_OUTPUT_DOLLAR_PREFIX_TESTS: list[StageTestCase] = [ + StageTestCase( + "output_field_dollar_prefixed", + docs=[{"_id": 1}], + pipeline=_out({"$foo": {"$sum": 1}}), + error_code=BUCKET_OUTPUT_DOLLAR_PREFIX_ERROR, + msg="$bucketAuto should reject $-prefixed output field name", + ), +] + +# Property [Output Field Dot Rejection]: output field names containing a dot +# are rejected. +BUCKET_AUTO_OUTPUT_DOT_TESTS: list[StageTestCase] = [ + StageTestCase( + "output_field_dot_middle", + docs=[{"_id": 1}], + pipeline=_out({"a.b": {"$sum": 1}}), + error_code=BUCKET_OUTPUT_DOT_ERROR, + msg="$bucketAuto should reject output field name containing a dot", + ), +] + +# Property [Output Field Not Accumulator]: output field values must be +# accumulator objects; non-accumulator values are rejected. +BUCKET_AUTO_OUTPUT_NOT_ACCUMULATOR_TESTS: list[StageTestCase] = [ + StageTestCase( + "output_field_int_value", + docs=[{"_id": 1}], + pipeline=_out({"f": 42}), + error_code=BUCKET_OUTPUT_NOT_ACCUMULATOR_ERROR, + msg="$bucketAuto should reject non-accumulator int value in output", + ), + StageTestCase( + "output_field_string_value", + docs=[{"_id": 1}], + pipeline=_out({"f": "hello"}), + error_code=BUCKET_OUTPUT_NOT_ACCUMULATOR_ERROR, + msg="$bucketAuto should reject non-accumulator string value in output", + ), +] + +BUCKET_AUTO_OUTPUT_ERROR_TESTS = ( + BUCKET_AUTO_OUTPUT_TYPE_TESTS + + BUCKET_AUTO_OUTPUT_DOLLAR_PREFIX_TESTS + + BUCKET_AUTO_OUTPUT_DOT_TESTS + + BUCKET_AUTO_OUTPUT_NOT_ACCUMULATOR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(BUCKET_AUTO_OUTPUT_ERROR_TESTS)) +def test_bucketAuto_output_errors(collection, test_case: StageTestCase): + """Test $bucketAuto output field validation errors.""" + populate_collection(collection, test_case) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/utils/__init__.py b/documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/utils/bucketAuto_common.py b/documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/utils/bucketAuto_common.py new file mode 100644 index 000000000..9fec6ed38 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/utils/bucketAuto_common.py @@ -0,0 +1,21 @@ +"""Shared constants for $bucketAuto tests.""" + +from __future__ import annotations + +# The preferred-number series accepted by the $bucketAuto 'granularity' option. +# See https://www.mongodb.com/docs/manual/reference/operator/aggregation/bucketAuto/ +GRANULARITY_VALUES = [ + "R5", + "R10", + "R20", + "R40", + "R80", + "1-2-5", + "E6", + "E12", + "E24", + "E48", + "E96", + "E192", + "POWERSOF2", +] diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/test_stages_position_bucketAuto.py b/documentdb_tests/compatibility/tests/core/operator/stages/test_stages_position_bucketAuto.py new file mode 100644 index 000000000..dbeb9777a --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/test_stages_position_bucketAuto.py @@ -0,0 +1,148 @@ +"""Tests for $bucketAuto composing with other stages in common use cases.""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, + populate_collection, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Pipeline Composition]: $bucketAuto composes correctly with other +# stages in realistic multi-stage pipelines. +BUCKET_AUTO_PIPELINE_COMPOSITION_TESTS: list[StageTestCase] = [ + StageTestCase( + "match_before_bucketAuto", + docs=[ + {"_id": 1, "status": "active", "score": 15}, + {"_id": 2, "status": "inactive", "score": 25}, + {"_id": 3, "status": "active", "score": 35}, + {"_id": 4, "status": "active", "score": 45}, + ], + pipeline=[ + {"$match": {"status": "active"}}, + {"$bucketAuto": {"groupBy": "$score", "buckets": 2}}, + ], + expected=[ + {"_id": {"min": 15, "max": 45}, "count": 2}, + {"_id": {"min": 45, "max": 45}, "count": 1}, + ], + msg="$bucketAuto should operate on documents filtered by a preceding $match", + ), + StageTestCase( + "sort_after_bucketAuto", + docs=[{"_id": i, "x": i} for i in range(1, 5)], + pipeline=[ + {"$bucketAuto": {"groupBy": "$x", "buckets": 2}}, + {"$sort": {"_id.min": -1}}, + ], + expected=[ + {"_id": {"min": 3, "max": 4}, "count": 2}, + {"_id": {"min": 1, "max": 3}, "count": 2}, + ], + msg="$bucketAuto output should be sortable by a following $sort on the bucket _id", + ), + StageTestCase( + "limit_after_bucketAuto", + docs=[{"_id": i, "x": i} for i in range(1, 7)], + pipeline=[ + {"$bucketAuto": {"groupBy": "$x", "buckets": 3}}, + {"$limit": 2}, + ], + expected=[ + {"_id": {"min": 1, "max": 3}, "count": 2}, + {"_id": {"min": 3, "max": 5}, "count": 2}, + ], + msg="$limit should truncate $bucketAuto output", + ), + StageTestCase( + "skip_after_bucketAuto", + docs=[{"_id": i, "x": i} for i in range(1, 7)], + pipeline=[ + {"$bucketAuto": {"groupBy": "$x", "buckets": 3}}, + {"$skip": 1}, + ], + expected=[ + {"_id": {"min": 3, "max": 5}, "count": 2}, + {"_id": {"min": 5, "max": 6}, "count": 2}, + ], + msg="$skip should skip $bucketAuto output buckets", + ), + StageTestCase( + "facet_multiple_bucketAuto", + docs=[{"_id": 1, "x": 5, "y": 50}, {"_id": 2, "x": 15, "y": 150}], + pipeline=[ + { + "$facet": { + "byX": [{"$bucketAuto": {"groupBy": "$x", "buckets": 1}}], + "byY": [{"$bucketAuto": {"groupBy": "$y", "buckets": 1}}], + } + } + ], + expected=[ + { + "byX": [{"_id": {"min": 5, "max": 15}, "count": 2}], + "byY": [{"_id": {"min": 50, "max": 150}, "count": 2}], + } + ], + msg="$bucketAuto should run inside multiple $facet sub-pipelines over different fields", + ), + StageTestCase( + "group_after_bucketAuto", + docs=[{"_id": i, "x": i} for i in range(1, 7)], + pipeline=[ + {"$bucketAuto": {"groupBy": "$x", "buckets": 3}}, + {"$group": {"_id": None, "avg_count": {"$avg": "$count"}}}, + ], + expected=[{"_id": None, "avg_count": 2.0}], + msg="$group should aggregate across $bucketAuto output", + ), + StageTestCase( + "project_after_bucketAuto", + docs=[ + {"_id": 1, "x": 5, "v": 10}, + {"_id": 2, "x": 5, "v": 20}, + {"_id": 3, "x": 15, "v": 30}, + ], + pipeline=[ + { + "$bucketAuto": { + "groupBy": "$x", + "buckets": 2, + "output": {"total": {"$sum": "$v"}, "n": {"$sum": 1}}, + } + }, + {"$project": {"avg": {"$divide": ["$total", "$n"]}}}, + ], + expected=[ + {"_id": {"min": 5, "max": 15}, "avg": 15.0}, + {"_id": {"min": 15, "max": 15}, "avg": 30.0}, + ], + msg="$project should compute on fields produced by $bucketAuto", + ), +] + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(BUCKET_AUTO_PIPELINE_COMPOSITION_TESTS)) +def test_bucketAuto_pipeline_composition(collection, test_case: StageTestCase): + """Test $bucketAuto composing with other stages in common use cases.""" + populate_collection(collection, test_case) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/framework/error_codes.py b/documentdb_tests/framework/error_codes.py index 2b562bb09..ef220d300 100644 --- a/documentdb_tests/framework/error_codes.py +++ b/documentdb_tests/framework/error_codes.py @@ -318,6 +318,19 @@ BUCKET_OUTPUT_DOLLAR_PREFIX_ERROR = 40236 GROUP_ACCUMULATOR_ARRAY_ARGUMENT_ERROR = 40237 GROUP_ACCUMULATOR_MULTIPLE_KEYS_ERROR = 40238 +BUCKET_AUTO_GROUPBY_NOT_EXPRESSION_ERROR = 40239 +BUCKET_AUTO_ARG_NOT_OBJECT_ERROR = 40240 +BUCKET_AUTO_BUCKETS_NOT_NUMERIC_ERROR = 40241 +BUCKET_AUTO_BUCKETS_NOT_INT_ERROR = 40242 +BUCKET_AUTO_BUCKETS_NOT_POSITIVE_ERROR = 40243 +BUCKET_AUTO_OUTPUT_NOT_OBJECT_ERROR = 40244 +BUCKET_AUTO_UNRECOGNIZED_OPTION_ERROR = 40245 +BUCKET_AUTO_MISSING_REQUIRED_ERROR = 40246 +BUCKET_AUTO_GRANULARITY_UNKNOWN_ERROR = 40257 +BUCKET_AUTO_GRANULARITY_NON_NUMERIC_ERROR = 40258 +BUCKET_AUTO_GRANULARITY_NAN_ERROR = 40259 +BUCKET_AUTO_GRANULARITY_NEGATIVE_ERROR = 40260 +BUCKET_AUTO_GRANULARITY_NOT_STRING_ERROR = 40261 SET_SPECIFICATION_NOT_OBJECT_ERROR = 40272 PIPELINE_STAGE_EXTRA_FIELD_ERROR = 40323 UNKNOWN_PIPELINE_STAGE_ERROR = 40324 From 40572f516fa50c35f331dc4d5f6753d259d24c3e Mon Sep 17 00:00:00 2001 From: "Alina (Xi) Li" <96995091+alinaliBQ@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:27:43 -0700 Subject: [PATCH 24/35] Add $map, $zip, and $range tests (#672) Signed-off-by: Alina (Xi) Li Signed-off-by: RyanGarfinkel <113050972+RyanGarfinkel@users.noreply.github.com> --- .../expressions/array/map/__init__.py | 0 .../array/map/test_map_as_errors.py | 128 +++++ .../array/map/test_map_bson_types.py | 312 ++++++++++++ .../array/map/test_map_core_behavior.py | 312 ++++++++++++ .../expressions/array/map/test_map_errors.py | 288 ++++++++++++ .../array/map/test_map_expressions.py | 244 ++++++++++ .../array/map/test_map_structure_errors.py | 110 +++++ ...ke_expression_map.py => test_smoke_map.py} | 2 +- .../expressions/array/range/__init__.py | 0 .../array/range/test_range_boundary.py | 87 ++++ .../array/range/test_range_core_behavior.py | 226 +++++++++ .../array/range/test_range_expressions.py | 156 ++++++ .../range/test_range_numeric_acceptance.py | 230 +++++++++ .../array/range/test_range_type_errors.py | 292 ++++++++++++ .../array/range/test_range_value_errors.py | 381 +++++++++++++++ ...xpression_range.py => test_smoke_range.py} | 2 +- .../expressions/array/zip/__init__.py | 0 ...ke_expression_zip.py => test_smoke_zip.py} | 2 +- .../zip/test_zip_argument_structure_errors.py | 134 ++++++ .../array/zip/test_zip_bson_types.py | 395 ++++++++++++++++ .../array/zip/test_zip_core_behavior.py | 230 +++++++++ .../array/zip/test_zip_degenerate_cases.py | 239 ++++++++++ .../expressions/array/zip/test_zip_errors.py | 443 ++++++++++++++++++ .../array/zip/test_zip_expressions.py | 239 ++++++++++ .../test_expressions_combination_map.py | 96 ++++ .../test_expressions_combination_range.py | 89 ++++ .../test_expressions_combination_zip.py | 126 +++++ 27 files changed, 4760 insertions(+), 3 deletions(-) create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/map/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/map/test_map_as_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/map/test_map_bson_types.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/map/test_map_core_behavior.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/map/test_map_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/map/test_map_expressions.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/map/test_map_structure_errors.py rename documentdb_tests/compatibility/tests/core/operator/expressions/array/map/{test_smoke_expression_map.py => test_smoke_map.py} (92%) create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/range/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/range/test_range_boundary.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/range/test_range_core_behavior.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/range/test_range_expressions.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/range/test_range_numeric_acceptance.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/range/test_range_type_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/range/test_range_value_errors.py rename documentdb_tests/compatibility/tests/core/operator/expressions/array/range/{test_smoke_expression_range.py => test_smoke_range.py} (89%) create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/zip/__init__.py rename documentdb_tests/compatibility/tests/core/operator/expressions/array/zip/{test_smoke_expression_zip.py => test_smoke_zip.py} (89%) create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/zip/test_zip_argument_structure_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/zip/test_zip_bson_types.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/zip/test_zip_core_behavior.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/zip/test_zip_degenerate_cases.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/zip/test_zip_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/zip/test_zip_expressions.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_map.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_range.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_zip.py diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/map/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/map/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/map/test_map_as_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/map/test_map_as_errors.py new file mode 100644 index 000000000..dd35ecb5d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/map/test_map_as_errors.py @@ -0,0 +1,128 @@ +""" +Error tests for $map 'as' parameter. + +Tests that $map rejects non-string 'as' values (including empty string). +""" + +from datetime import datetime + +import pytest +from bson import Binary, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( # noqa: E501 + assert_expression_result, + execute_expression, +) +from documentdb_tests.framework.error_codes import FAILED_TO_PARSE_ERROR +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import FLOAT_INFINITY, FLOAT_NAN + +# Property [Invalid As Type]: $map rejects non-string types for the as parameter. +INVALID_AS_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "type_int", + expression={"$map": {"input": [1, 2, 3], "as": 1, "in": "$$this"}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$map should reject int as variable name", + ), + ExpressionTestCase( + "type_long", + expression={"$map": {"input": [1, 2, 3], "as": Int64(1), "in": "$$this"}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$map should reject Int64 as variable name", + ), + ExpressionTestCase( + "type_object", + expression={"$map": {"input": [1, 2, 3], "as": {"a": 1}, "in": "$$this"}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$map should reject object as variable name", + ), + ExpressionTestCase( + "type_array", + expression={"$map": {"input": [1, 2, 3], "as": [1], "in": "$$this"}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$map should reject array as variable name", + ), + ExpressionTestCase( + "type_minkey", + expression={"$map": {"input": [1, 2, 3], "as": MinKey(), "in": "$$this"}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$map should reject MinKey as variable name", + ), + ExpressionTestCase( + "type_maxkey", + expression={"$map": {"input": [1, 2, 3], "as": MaxKey(), "in": "$$this"}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$map should reject MaxKey as variable name", + ), + ExpressionTestCase( + "type_bindata", + expression={"$map": {"input": [1, 2, 3], "as": Binary(b"\x00", 0), "in": "$$this"}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$map should reject Binary as variable name", + ), + ExpressionTestCase( + "type_objectid", + expression={"$map": {"input": [1, 2, 3], "as": ObjectId(), "in": "$$this"}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$map should reject ObjectId as variable name", + ), + ExpressionTestCase( + "type_date", + expression={"$map": {"input": [1, 2, 3], "as": datetime(2026, 1, 1), "in": "$$this"}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$map should reject datetime as variable name", + ), + ExpressionTestCase( + "type_timestamp", + expression={"$map": {"input": [1, 2, 3], "as": Timestamp(0, 0), "in": "$$this"}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$map should reject Timestamp as variable name", + ), + ExpressionTestCase( + "type_regex", + expression={"$map": {"input": [1, 2, 3], "as": Regex("pattern"), "in": "$$this"}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$map should reject Regex as variable name", + ), + ExpressionTestCase( + "type_bool_true", + expression={"$map": {"input": [1, 2, 3], "as": True, "in": "$$this"}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$map should reject boolean as variable name", + ), + ExpressionTestCase( + "type_null", + expression={"$map": {"input": [1, 2, 3], "as": None, "in": "$$this"}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$map should reject null as variable name", + ), + ExpressionTestCase( + "type_empty_string", + expression={"$map": {"input": [1, 2, 3], "as": "", "in": "$$this"}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$map should reject empty string as variable name", + ), + ExpressionTestCase( + "type_nan", + expression={"$map": {"input": [1, 2, 3], "as": FLOAT_NAN, "in": "$$this"}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$map should reject NaN as variable name", + ), + ExpressionTestCase( + "type_infinity", + expression={"$map": {"input": [1, 2, 3], "as": FLOAT_INFINITY, "in": "$$this"}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$map should reject Infinity as variable name", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(INVALID_AS_TYPE_TESTS)) +def test_map_invalid_as(collection, test): + """Test $map with invalid 'as' parameter values.""" + result = execute_expression(collection, test.expression) + assert_expression_result(result, error_code=test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/map/test_map_bson_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/map/test_map_bson_types.py new file mode 100644 index 000000000..70781de24 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/map/test_map_bson_types.py @@ -0,0 +1,312 @@ +""" +BSON type element preservation tests for $map expression. + +Tests that various BSON types are preserved when mapping over arrays, +including special numeric values and boundary values. +""" + +from datetime import datetime, timezone +from uuid import UUID + +import pytest +from bson import Binary, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( # noqa: E501 + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ONE_AND_HALF, + DECIMAL128_TRAILING_ZERO, + DECIMAL128_TWO_AND_HALF, + DOUBLE_NEGATIVE_ZERO, + FLOAT_INFINITY, + FLOAT_NEGATIVE_INFINITY, + INT32_MAX, + INT32_MIN, + INT64_MAX, + INT64_MIN, +) + +# Property [Type Preservation]: $map preserves each element's BSON type. +BSON_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int64_values", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": [Int64(1), Int64(2), Int64(3)]}, + expected=[Int64(1), Int64(2), Int64(3)], + msg="$map should preserve Int64 values", + ), + ExpressionTestCase( + "decimal128_values", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": [DECIMAL128_ONE_AND_HALF, DECIMAL128_TWO_AND_HALF]}, + expected=[DECIMAL128_ONE_AND_HALF, DECIMAL128_TWO_AND_HALF], + msg="$map should preserve Decimal128 values", + ), + ExpressionTestCase( + "datetime_values", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={ + "arr": [ + datetime(2024, 1, 1, tzinfo=timezone.utc), + datetime(2024, 6, 1, tzinfo=timezone.utc), + ] + }, + expected=[ + datetime(2024, 1, 1, tzinfo=timezone.utc), + datetime(2024, 6, 1, tzinfo=timezone.utc), + ], + msg="$map should preserve datetime values", + ), + ExpressionTestCase( + "objectid_values", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": [ObjectId("000000000000000000000001"), ObjectId("000000000000000000000002")]}, + expected=[ObjectId("000000000000000000000001"), ObjectId("000000000000000000000002")], + msg="$map should preserve ObjectId values", + ), + ExpressionTestCase( + "binary_values", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": [Binary(b"\x01", 0), Binary(b"\x02", 0)]}, + expected=[b"\x01", b"\x02"], + msg="$map should preserve Binary values", + ), + ExpressionTestCase( + "binary_subtype_preservation", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": [Binary(b"\x01", 128), Binary(b"\x02", 128)]}, + expected=[Binary(b"\x01", 128), Binary(b"\x02", 128)], + msg="$map should preserve Binary subtype", + ), + ExpressionTestCase( + "regex_values", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": [Regex("^a", "i"), Regex("^b", "i")]}, + expected=[Regex("^a", "i"), Regex("^b", "i")], + msg="$map should preserve Regex values", + ), + ExpressionTestCase( + "timestamp_values", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": [Timestamp(1, 0), Timestamp(2, 0)]}, + expected=[Timestamp(1, 0), Timestamp(2, 0)], + msg="$map should preserve Timestamp values", + ), + ExpressionTestCase( + "minkey_maxkey", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": [MinKey(), MaxKey()]}, + expected=[MinKey(), MaxKey()], + msg="$map should preserve MinKey/MaxKey values", + ), + ExpressionTestCase( + "uuid_values", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={ + "arr": [ + Binary.from_uuid(UUID("01234567-89ab-cdef-fedc-ba9876543210")), + Binary.from_uuid(UUID("fedcba98-7654-3210-0123-456789abcdef")), + ] + }, + expected=[ + Binary.from_uuid(UUID("01234567-89ab-cdef-fedc-ba9876543210")), + Binary.from_uuid(UUID("fedcba98-7654-3210-0123-456789abcdef")), + ], + msg="$map should preserve UUID binary values", + ), +] + +# Property [Mixed Types]: $map processes arrays with mixed BSON types. +MIXED_BSON_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "mixed_bson_types", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": [1, "two", Int64(3), Decimal128("4"), True, None, MinKey()]}, + expected=[1, "two", Int64(3), Decimal128("4"), True, None, MinKey()], + msg="$map should preserve mixed BSON types via identity", + ), + ExpressionTestCase( + "mixed_dates_and_ids", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={ + "arr": [ + datetime(2024, 1, 1, tzinfo=timezone.utc), + ObjectId("000000000000000000000001"), + Timestamp(1, 0), + ] + }, + expected=[ + datetime(2024, 1, 1, tzinfo=timezone.utc), + ObjectId("000000000000000000000001"), + Timestamp(1, 0), + ], + msg="$map should preserve dates, ObjectIds, timestamps", + ), +] + +# Property [Special Numerics]: $map preserves NaN, Infinity, and boundary values. +SPECIAL_NUMERIC_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "infinity_values", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": [FLOAT_INFINITY, FLOAT_NEGATIVE_INFINITY]}, + expected=[FLOAT_INFINITY, FLOAT_NEGATIVE_INFINITY], + msg="$map should preserve infinity values", + ), + ExpressionTestCase( + "decimal128_infinity", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": [DECIMAL128_INFINITY, DECIMAL128_NEGATIVE_INFINITY]}, + expected=[DECIMAL128_INFINITY, DECIMAL128_NEGATIVE_INFINITY], + msg="$map should preserve Decimal128 infinity values", + ), + ExpressionTestCase( + "boundary_values", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": [INT32_MIN, INT32_MAX, INT64_MIN, INT64_MAX]}, + expected=[INT32_MIN, INT32_MAX, INT64_MIN, INT64_MAX], + msg="$map should preserve numeric boundary values", + ), + ExpressionTestCase( + "negative_zero", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": [DOUBLE_NEGATIVE_ZERO, DECIMAL128_NEGATIVE_ZERO]}, + expected=[DOUBLE_NEGATIVE_ZERO, DECIMAL128_NEGATIVE_ZERO], + msg="$map should preserve negative zero values", + ), +] + +# Property [Decimal128 Precision]: $map preserves Decimal128 trailing zeros and precision. +DECIMAL128_PRECISION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "decimal128_trailing_zeros", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": [DECIMAL128_TRAILING_ZERO, Decimal128("1.00"), Decimal128("1.000")]}, + expected=[DECIMAL128_TRAILING_ZERO, Decimal128("1.00"), Decimal128("1.000")], + msg="$map decimal128 trailing zeros preserved", + ), + ExpressionTestCase( + "decimal128_nan", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": [DECIMAL128_NAN, Decimal128("1")]}, + expected=[DECIMAL128_NAN, Decimal128("1")], + msg="$map decimal128 NaN preserved", + ), +] + +# Property [Type Transform]: $map transforms elements using type-specific operations. +BSON_TRANSFORM_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "multiply_int64", + expression={"$map": {"input": "$arr", "in": {"$multiply": ["$$this", Int64(2)]}}}, + doc={"arr": [Int64(10), Int64(20), Int64(30)]}, + expected=[Int64(20), Int64(40), Int64(60)], + msg="$multiply on Int64 should preserve Int64 type", + ), + ExpressionTestCase( + "add_decimal128", + expression={"$map": {"input": "$arr", "in": {"$add": ["$$this", Decimal128("0.1")]}}}, + doc={"arr": [DECIMAL128_ONE_AND_HALF, DECIMAL128_TWO_AND_HALF, Decimal128("3.5")]}, + expected=[Decimal128("1.6"), Decimal128("2.6"), Decimal128("3.6")], + msg="$add on Decimal128 should preserve precision", + ), + ExpressionTestCase( + "type_of_mixed_bson", + expression={"$map": {"input": "$arr", "in": {"$type": "$$this"}}}, + doc={"arr": [1, "two", Int64(3), Decimal128("4"), True, None]}, + expected=["int", "string", "long", "decimal", "bool", "null"], + msg="$type on mixed BSON types", + ), + ExpressionTestCase( + "dateToString_datetime", + expression={ + "$map": { + "input": "$arr", + "in": {"$dateToString": {"format": "%Y-%m-%d", "date": "$$this"}}, + } + }, + doc={ + "arr": [ + datetime(2024, 1, 1, tzinfo=timezone.utc), + datetime(2024, 6, 15, tzinfo=timezone.utc), + ] + }, + expected=["2024-01-01", "2024-06-15"], + msg="$dateToString on datetime array", + ), + ExpressionTestCase( + "toString_objectid", + expression={"$map": {"input": "$arr", "in": {"$toString": "$$this"}}}, + doc={"arr": [ObjectId("000000000000000000000001"), ObjectId("000000000000000000000002")]}, + expected=["000000000000000000000001", "000000000000000000000002"], + msg="$toString on ObjectId array", + ), + ExpressionTestCase( + "toLong_int_values", + expression={"$map": {"input": "$arr", "in": {"$toLong": "$$this"}}}, + doc={"arr": [1, 2, 3]}, + expected=[Int64(1), Int64(2), Int64(3)], + msg="$toLong converts int to Int64", + ), + ExpressionTestCase( + "toDouble_decimal128", + expression={"$map": {"input": "$arr", "in": {"$toDouble": "$$this"}}}, + doc={"arr": [DECIMAL128_ONE_AND_HALF, Decimal128("2.0")]}, + expected=[1.5, 2.0], + msg="$toDouble on Decimal128 array", + ), + ExpressionTestCase( + "concat_strings", + expression={"$map": {"input": "$arr", "in": {"$concat": ["$$this", "!"]}}}, + doc={"arr": ["hello", "world"]}, + expected=["hello!", "world!"], + msg="$concat on string array", + ), + ExpressionTestCase( + "add_millis_to_datetime", + expression={"$map": {"input": "$arr", "in": {"$add": ["$$this", 86400000]}}}, + doc={ + "arr": [ + datetime(2024, 1, 1, tzinfo=timezone.utc), + datetime(2024, 6, 1, tzinfo=timezone.utc), + ] + }, + expected=[ + datetime(2024, 1, 2, tzinfo=timezone.utc), + datetime(2024, 6, 2, tzinfo=timezone.utc), + ], + msg="$map add one day in millis to datetime array", + ), + ExpressionTestCase( + "subtract_int64", + expression={"$map": {"input": "$arr", "in": {"$subtract": ["$$this", Int64(50)]}}}, + doc={"arr": [Int64(100), Int64(200), Int64(300)]}, + expected=[Int64(50), Int64(150), Int64(250)], + msg="$subtract on Int64 array", + ), +] + +ALL_BSON_TESTS = ( + BSON_TYPE_TESTS + + MIXED_BSON_TESTS + + SPECIAL_NUMERIC_TESTS + + DECIMAL128_PRECISION_TESTS + + BSON_TRANSFORM_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_BSON_TESTS)) +def test_map_bson_type_preservation(collection, test): + """Test $map preserves BSON types and handles type-specific transforms.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result(result, expected=test.expected, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/map/test_map_core_behavior.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/map/test_map_core_behavior.py new file mode 100644 index 000000000..49c823396 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/map/test_map_core_behavior.py @@ -0,0 +1,312 @@ +""" +Core behavior tests for $map expression. + +Tests basic mapping, identity transforms, nested arrays, null propagation, +empty arrays, various in expressions, default and custom 'as' variable, +and large arrays. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( # noqa: E501 + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Basic Transform]: $map applies an expression to each element. +BASIC_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "multiply_each", + expression={"$map": {"input": "$arr", "in": {"$multiply": ["$$this", 2]}}}, + doc={"arr": [1, 2, 3]}, + expected=[2, 4, 6], + msg="$map should multiply each element by 2", + ), + ExpressionTestCase( + "add_each", + expression={"$map": {"input": "$arr", "in": {"$add": ["$$this", 5]}}}, + doc={"arr": [10, 20, 30]}, + expected=[15, 25, 35], + msg="$map should add 5 to each element", + ), + ExpressionTestCase( + "identity", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": [1, 2, 3]}, + expected=[1, 2, 3], + msg="$map identity transform should return same array", + ), + ExpressionTestCase( + "constant_in", + expression={"$map": {"input": "$arr", "in": 13}}, + doc={"arr": [1, 2, 3]}, + expected=[13, 13, 13], + msg="$map constant in expression should repeat for each element", + ), + ExpressionTestCase( + "string_elements", + expression={"$map": {"input": "$arr", "in": {"$toUpper": "$$this"}}}, + doc={"arr": ["a", "b", "c"]}, + expected=["A", "B", "C"], + msg="$map should uppercase each string element", + ), + ExpressionTestCase( + "bool_elements", + expression={"$map": {"input": "$arr", "in": {"$not": "$$this"}}}, + doc={"arr": [True, False, True]}, + expected=[False, True, False], + msg="$map should negate each boolean element", + ), + ExpressionTestCase( + "single_element", + expression={"$map": {"input": "$arr", "in": {"$add": ["$$this", 1]}}}, + doc={"arr": [42]}, + expected=[43], + msg="$map should map single element", + ), + ExpressionTestCase( + "empty_array", + expression={"$map": {"input": "$arr", "in": {"$multiply": ["$$this", 2]}}}, + doc={"arr": []}, + expected=[], + msg="$map should return empty array for empty input", + ), + ExpressionTestCase( + "null_input", + expression={"$map": {"input": "$arr", "in": {"$multiply": ["$$this", 2]}}}, + doc={"arr": None}, + expected=None, + msg="$map should return null when input is null", + ), + ExpressionTestCase( + "custom_as_var", + expression={"$map": {"input": "$arr", "as": "val", "in": {"$multiply": ["$$val", 3]}}}, + doc={"arr": [1, 2, 3]}, + expected=[3, 6, 9], + msg="$map should use custom 'as' variable name", + ), +] + +# Property [Nested Arrays]: $map operates on nested array structures. +NESTED_ARRAY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_arrays_identity", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": [[1, 2], [3, 4]]}, + expected=[[1, 2], [3, 4]], + msg="$map should preserve nested arrays", + ), + ExpressionTestCase( + "nested_arrays_size", + expression={"$map": {"input": "$arr", "in": {"$size": "$$this"}}}, + doc={"arr": [[1, 2], [3, 4, 5], []]}, + expected=[2, 3, 0], + msg="$map should compute size of each subarray", + ), + ExpressionTestCase( + "extract_field_from_objects", + expression={"$map": {"input": "$arr", "in": "$$this.a"}}, + doc={"arr": [{"a": 1, "b": 2}, {"a": 3, "b": 4}]}, + expected=[1, 3], + msg="$map should extract field from each object element", + ), +] + +# Property [Null Elements]: $map preserves null elements within the array. +NULL_ELEMENT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_elements_identity", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": [None, 1, None]}, + expected=[None, 1, None], + msg="$map should preserve null elements", + ), + ExpressionTestCase( + "null_elements_ifnull", + expression={"$map": {"input": "$arr", "in": {"$ifNull": ["$$this", 0]}}}, + doc={"arr": [None, 1, None]}, + expected=[0, 1, 0], + msg="$map should replace null elements with $ifNull", + ), +] + +# Property [Element Wrapping]: $map wraps each element in the specified structure. +WRAP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "wrap_in_array", + expression={"$map": {"input": "$arr", "in": ["$$this"]}}, + doc={"arr": [1, 2, 3]}, + expected=[[1], [2], [3]], + msg="$map should wrap each element in an array", + ), + ExpressionTestCase( + "wrap_in_object", + expression={"$map": {"input": "$arr", "in": {"val": "$$this"}}}, + doc={"arr": [1, 2, 3]}, + expected=[{"val": 1}, {"val": 2}, {"val": 3}], + msg="$map should wrap each element in an object", + ), +] + +# Property [Type Conversion]: $map applies type conversion expressions to each element. +TYPE_CONVERSION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "to_string", + expression={"$map": {"input": "$arr", "in": {"$toString": "$$this"}}}, + doc={"arr": [1, 2, 3]}, + expected=["1", "2", "3"], + msg="$map should convert each element to string", + ), + ExpressionTestCase( + "type_of_each", + expression={"$map": {"input": "$arr", "in": {"$type": "$$this"}}}, + doc={"arr": [1, "two", True, None]}, + expected=["int", "string", "bool", "null"], + msg="$map should return type of each element", + ), +] + +# Property [Large Arrays]: $map handles large arrays. +LARGE_ARRAY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "large_array_1000", + expression={"$map": {"input": "$arr", "in": {"$multiply": ["$$this", 2]}}}, + doc={"arr": list(range(1000))}, + expected=[i * 2 for i in range(1000)], + msg="$map should map over 1000 elements", + ), +] + +# Property [Order Preservation]: $map preserves element order and duplicates. +ORDER_DUPLICATE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "preserves_order", + expression={"$map": {"input": "$arr", "in": {"$multiply": ["$$this", 10]}}}, + doc={"arr": [3, 1, 4, 1, 5, 9]}, + expected=[30, 10, 40, 10, 50, 90], + msg="$map should preserve element order", + ), + ExpressionTestCase( + "duplicate_elements", + expression={"$map": {"input": "$arr", "in": {"$add": ["$$this", 10]}}}, + doc={"arr": [1, 1, 1, 1]}, + expected=[11, 11, 11, 11], + msg="$map should process all duplicate elements", + ), + ExpressionTestCase( + "duplicate_nulls", + expression={"$map": {"input": "$arr", "in": {"$ifNull": ["$$this", 0]}}}, + doc={"arr": [None, None, None]}, + expected=[0, 0, 0], + msg="$map should process all null duplicates", + ), +] + + +# Property [Null Propagation]: $map returns null when input is null. +NULL_PROPAGATION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_add_propagation", + expression={"$map": {"input": "$arr", "in": {"$add": ["$$this", 1]}}}, + doc={"arr": [None, None]}, + expected=[None, None], + msg="$map null + number should propagate null", + ), + ExpressionTestCase( + "null_in_mixed_add", + expression={"$map": {"input": "$arr", "in": {"$add": ["$$this", 1]}}}, + doc={"arr": [1, 2, None, 4]}, + expected=[2, 3, None, 5], + msg="$map null element should propagate, others should add", + ), + ExpressionTestCase( + "null_input_ignores_in", + expression={"$map": {"input": "$arr", "in": {"$add": [2, 1]}}}, + doc={"arr": None}, + expected=None, + msg="$map null input returns null regardless of in expression", + ), + ExpressionTestCase( + "null_element_in_ignores", + expression={"$map": {"input": "$arr", "in": {"$add": [2, 1]}}}, + doc={"arr": [None]}, + expected=[3], + msg="$map expression ignoring element should still produce result", + ), +] + +# Property [Conditional Expressions]: $map supports conditional logic in the in expression. +CONDITIONAL_IN_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "cond_classify", + expression={ + "$map": {"input": "$arr", "in": {"$cond": [{"$gte": ["$$this", 3]}, "high", "low"]}} + }, + doc={"arr": [1, 2, 3, 4]}, + expected=["low", "low", "high", "high"], + msg="$cond should classify elements", + ), + ExpressionTestCase( + "cond_isarray", + expression={ + "$map": { + "input": "$arr", + "in": {"$cond": [{"$isArray": "$$this"}, {"$size": "$$this"}, -1]}, + } + }, + doc={"arr": [[1, 2], "notArray", [3, 4, 5]]}, + expected=[2, -1, 3], + msg="$cond with $isArray should work", + ), +] + +# Property [Nested Expressions]: $map supports deeply nested operator expressions. +NESTED_IN_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_multiply_add", + expression={"$map": {"input": "$arr", "in": {"$multiply": [{"$add": ["$$this", 1]}, 2]}}}, + doc={"arr": [1, 2, 3]}, + expected=[4, 6, 8], + msg="$map nested expression should work", + ), + ExpressionTestCase( + "self_reference_double", + expression={"$map": {"input": "$arr", "in": {"$add": ["$$this", "$$this"]}}}, + doc={"arr": [1, 2, 3]}, + expected=[2, 4, 6], + msg="$map self-reference should double each element", + ), + ExpressionTestCase( + "produce_nested_arrays", + expression={"$map": {"input": "$arr", "in": ["$$this", {"$multiply": ["$$this", 2]}]}}, + doc={"arr": [1, 2, 3]}, + expected=[[1, 2], [2, 4], [3, 6]], + msg="$map should produce nested arrays", + ), +] + +ALL_TESTS = ( + BASIC_TESTS + + NESTED_ARRAY_TESTS + + NULL_ELEMENT_TESTS + + WRAP_TESTS + + TYPE_CONVERSION_TESTS + + LARGE_ARRAY_TESTS + + ORDER_DUPLICATE_TESTS + + NULL_PROPAGATION_TESTS + + CONDITIONAL_IN_TESTS + + NESTED_IN_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_map_core_behavior(collection, test): + """Test $map core behavior: transforms, null propagation, conditionals.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/map/test_map_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/map/test_map_errors.py new file mode 100644 index 000000000..98440c698 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/map/test_map_errors.py @@ -0,0 +1,288 @@ +""" +Error tests for $map expression. + +Tests non-array input (all BSON types, special numeric values, boundary values), +structural errors (missing fields, unknown fields). +Note: $map propagates null — null input returns null (tested in core_behavior). +""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( # noqa: E501 + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + MAP_INPUT_NOT_ARRAY_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_MAX, + DECIMAL128_MIN, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_NAN, + DECIMAL128_NEGATIVE_ZERO, + DOUBLE_NEGATIVE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + INT32_MAX, + INT32_MIN, + INT64_MAX, + INT64_MIN, +) + +# Property [Non-Array Input]: $map rejects non-array input with all BSON types. +NOT_ARRAY_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "string_input", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": "hello"}, + error_code=MAP_INPUT_NOT_ARRAY_ERROR, + msg="$map should reject string input", + ), + ExpressionTestCase( + "int_input", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": 42}, + error_code=MAP_INPUT_NOT_ARRAY_ERROR, + msg="$map should reject int input", + ), + ExpressionTestCase( + "negative_int_input", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": -42}, + error_code=MAP_INPUT_NOT_ARRAY_ERROR, + msg="$map should reject negative int input", + ), + ExpressionTestCase( + "bool_input", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": True}, + error_code=MAP_INPUT_NOT_ARRAY_ERROR, + msg="$map should reject bool input", + ), + ExpressionTestCase( + "object_input", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": {"a": 1}}, + error_code=MAP_INPUT_NOT_ARRAY_ERROR, + msg="$map should reject object input", + ), + ExpressionTestCase( + "double_input", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": 3.14}, + error_code=MAP_INPUT_NOT_ARRAY_ERROR, + msg="$map should reject double input", + ), + ExpressionTestCase( + "negative_double_input", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": -3.14}, + error_code=MAP_INPUT_NOT_ARRAY_ERROR, + msg="$map should reject negative double input", + ), + ExpressionTestCase( + "decimal128_input", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": Decimal128("1")}, + error_code=MAP_INPUT_NOT_ARRAY_ERROR, + msg="$map should reject decimal128 input", + ), + ExpressionTestCase( + "int64_input", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": Int64(1)}, + error_code=MAP_INPUT_NOT_ARRAY_ERROR, + msg="$map should reject int64 input", + ), + ExpressionTestCase( + "objectid_input", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": ObjectId()}, + error_code=MAP_INPUT_NOT_ARRAY_ERROR, + msg="$map should reject objectid input", + ), + ExpressionTestCase( + "datetime_input", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + error_code=MAP_INPUT_NOT_ARRAY_ERROR, + msg="$map should reject datetime input", + ), + ExpressionTestCase( + "binary_input", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": Binary(b"x", 0)}, + error_code=MAP_INPUT_NOT_ARRAY_ERROR, + msg="$map should reject binary input", + ), + ExpressionTestCase( + "regex_input", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": Regex("x")}, + error_code=MAP_INPUT_NOT_ARRAY_ERROR, + msg="$map should reject regex input", + ), + ExpressionTestCase( + "code_input", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": Code("function(){}")}, + error_code=MAP_INPUT_NOT_ARRAY_ERROR, + msg="$map should reject javascript code input", + ), + ExpressionTestCase( + "maxkey_input", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": MaxKey()}, + error_code=MAP_INPUT_NOT_ARRAY_ERROR, + msg="$map should reject maxkey input", + ), + ExpressionTestCase( + "minkey_input", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": MinKey()}, + error_code=MAP_INPUT_NOT_ARRAY_ERROR, + msg="$map should reject minkey input", + ), + ExpressionTestCase( + "timestamp_input", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": Timestamp(0, 0)}, + error_code=MAP_INPUT_NOT_ARRAY_ERROR, + msg="$map should reject timestamp input", + ), +] + +# Property [Special Numeric Input]: $map rejects special numeric values as input. +SPECIAL_NUMERIC_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nan_input", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": FLOAT_NAN}, + error_code=MAP_INPUT_NOT_ARRAY_ERROR, + msg="$map should reject NaN input", + ), + ExpressionTestCase( + "inf_input", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": FLOAT_INFINITY}, + error_code=MAP_INPUT_NOT_ARRAY_ERROR, + msg="$map should reject Infinity input", + ), + ExpressionTestCase( + "neg_inf_input", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": FLOAT_NEGATIVE_INFINITY}, + error_code=MAP_INPUT_NOT_ARRAY_ERROR, + msg="$map should reject -Infinity input", + ), + ExpressionTestCase( + "neg_zero_input", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": DOUBLE_NEGATIVE_ZERO}, + error_code=MAP_INPUT_NOT_ARRAY_ERROR, + msg="$map should reject negative zero input", + ), + ExpressionTestCase( + "decimal128_nan_input", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": DECIMAL128_NAN}, + error_code=MAP_INPUT_NOT_ARRAY_ERROR, + msg="$map should reject Decimal128 NaN input", + ), + ExpressionTestCase( + "decimal128_neg_nan_input", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": DECIMAL128_NEGATIVE_NAN}, + error_code=MAP_INPUT_NOT_ARRAY_ERROR, + msg="$map should reject Decimal128 -NaN input", + ), + ExpressionTestCase( + "decimal128_inf_input", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": DECIMAL128_INFINITY}, + error_code=MAP_INPUT_NOT_ARRAY_ERROR, + msg="$map should reject Decimal128 Infinity input", + ), + ExpressionTestCase( + "decimal128_neg_inf_input", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": DECIMAL128_NEGATIVE_INFINITY}, + error_code=MAP_INPUT_NOT_ARRAY_ERROR, + msg="$map should reject Decimal128 -Infinity input", + ), + ExpressionTestCase( + "decimal128_neg_zero_input", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": DECIMAL128_NEGATIVE_ZERO}, + error_code=MAP_INPUT_NOT_ARRAY_ERROR, + msg="$map should reject Decimal128 -0 input", + ), +] + +# Property [Boundary Input]: $map rejects numeric boundary values as input. +BOUNDARY_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int32_max_input", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": INT32_MAX}, + error_code=MAP_INPUT_NOT_ARRAY_ERROR, + msg="$map should reject INT32_MAX input", + ), + ExpressionTestCase( + "int32_min_input", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": INT32_MIN}, + error_code=MAP_INPUT_NOT_ARRAY_ERROR, + msg="$map should reject INT32_MIN input", + ), + ExpressionTestCase( + "int64_max_input", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": INT64_MAX}, + error_code=MAP_INPUT_NOT_ARRAY_ERROR, + msg="$map should reject INT64_MAX input", + ), + ExpressionTestCase( + "int64_min_input", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": INT64_MIN}, + error_code=MAP_INPUT_NOT_ARRAY_ERROR, + msg="$map should reject INT64_MIN input", + ), + ExpressionTestCase( + "decimal128_max_input", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": DECIMAL128_MAX}, + error_code=MAP_INPUT_NOT_ARRAY_ERROR, + msg="$map should reject DECIMAL128_MAX input", + ), + ExpressionTestCase( + "decimal128_min_input", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": DECIMAL128_MIN}, + error_code=MAP_INPUT_NOT_ARRAY_ERROR, + msg="$map should reject DECIMAL128_MIN input", + ), +] + +ALL_TESTS = NOT_ARRAY_ERROR_TESTS + SPECIAL_NUMERIC_ERROR_TESTS + BOUNDARY_ERROR_TESTS + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_map_non_array_input_error(collection, test): + """Test $map rejects non-array input with correct error code.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/map/test_map_expressions.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/map/test_map_expressions.py new file mode 100644 index 000000000..c983ce8c0 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/map/test_map_expressions.py @@ -0,0 +1,244 @@ +""" +Expression and field path tests for $map expression. + +Tests field path lookups, composite paths, system variables, +null/missing propagation via expressions, nested $map, and self-composition. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( # noqa: E501 + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import MISSING + +# Property [Field Path Resolution]: $map resolves nested and composite field paths. +# Property [Field Lookup]: $map resolves field paths in expressions. +FIELD_LOOKUP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_field_path", + expression={"$map": {"input": "$a.b", "in": {"$multiply": ["$$this", 2]}}}, + doc={"a": {"b": [1, 2, 3]}}, + expected=[2, 4, 6], + msg="$map should resolve nested field path", + ), + ExpressionTestCase( + "deeply_nested_field", + expression={"$map": {"input": "$a.b.c", "in": "$$this"}}, + doc={"a": {"b": {"c": [10, 20]}}}, + expected=[10, 20], + msg="$map should resolve deeply nested field path", + ), + ExpressionTestCase( + "composite_array_path", + expression={"$map": {"input": "$a.b", "in": {"$multiply": ["$$this", 10]}}}, + doc={"a": [{"b": 1}, {"b": 2}, {"b": 3}]}, + expected=[10, 20, 30], + msg="$map composite array path should resolve to array", + ), + ExpressionTestCase( + "index_path_on_object_key", + expression={"$map": {"input": "$a.0.b", "in": "$$this"}}, + doc={"a": {"0": {"b": [1, 2, 3]}}}, + expected=[1, 2, 3], + msg="$map object key '0' resolves correctly", + ), + ExpressionTestCase( + "object_key_zero", + expression={"$map": {"input": "$a.0", "in": "$$this"}}, + doc={"a": {"0": [1, 2, 3]}}, + expected=[1, 2, 3], + msg="$a.0 resolves as field named '0' on object", + ), + ExpressionTestCase( + "access_outer_field", + expression={"$map": {"input": "$arr", "in": {"$add": ["$$this", "$val"]}}}, + doc={"arr": [1, 2, 3], "val": 100}, + expected=[101, 102, 103], + msg="$map should access outer document field in 'in' expression", + ), + ExpressionTestCase( + "array_expression_input", + expression={"$map": {"input": ["$x", "$y"], "in": {"$multiply": ["$$this", 2]}}}, + doc={"x": 1, "y": 2}, + expected=[2, 4], + msg="$map array expression with field refs resolved", + ), +] + +# Property [Variables]: $map works with $let and system variables. +LET_AND_VARIABLE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "let_variable", + expression={ + "$let": { + "vars": {"arr": "$values"}, + "in": {"$map": {"input": "$$arr", "in": {"$add": ["$$this", 1]}}}, + } + }, + doc={"values": [1, 2, 3]}, + expected=[2, 3, 4], + msg="$map should work with $let variables", + ), + ExpressionTestCase( + "root_variable", + expression={"$map": {"input": "$$ROOT.values", "in": "$$this"}}, + doc={"_id": 1, "values": [10, 20]}, + expected=[10, 20], + msg="$map should work with $$ROOT", + ), + ExpressionTestCase( + "current_variable", + expression={"$map": {"input": "$$CURRENT.values", "in": "$$this"}}, + doc={"_id": 2, "values": [10, 20]}, + expected=[10, 20], + msg="$$CURRENT should be equivalent to field path", + ), +] + +# Property [Null/Missing Fields]: $map handles null and missing field paths. +NULL_MISSING_EXPR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "missing_field", + expression={"$map": {"input": "$nonexistent", "in": "$$this"}}, + doc={"other": 1}, + expected=None, + msg="$map missing field should return null", + ), + ExpressionTestCase( + "missing_input_type_is_null", + expression={"$type": {"$map": {"input": "$nonexistent", "in": "$$this"}}}, + doc={"x": 1}, + expected="null", + msg="$map missing field should produce null type", + ), + ExpressionTestCase( + "remove_variable", + expression={"$map": {"input": "$$REMOVE", "in": "$$this"}}, + doc={"x": 1}, + expected=None, + msg="$$REMOVE propagates null", + ), + ExpressionTestCase( + "missing_field_in_expression", + expression={"$map": {"input": "$arr", "in": MISSING}}, + doc={"arr": [1, 2, 3]}, + expected=[None, None, None], + msg="$map missing field in 'in' should produce null for each element", + ), +] + +# Property [Nested Map]: $map can be nested inside another $map. +NESTED_MAP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_map", + expression={ + "$map": { + "input": "$arr", + "as": "inarr", + "in": { + "$map": { + "input": "$$inarr", + "as": "num", + "in": {"$multiply": ["$$num", 2]}, + } + }, + } + }, + doc={"arr": [[1, 2], [3, 4]]}, + expected=[[2, 4], [6, 8]], + msg="$map nested $map should process 2D array", + ), +] + + +# Property [Reduce Interaction]: $map output works with $reduce. +REDUCE_INTERACTION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "map_within_reduce", + expression={ + "$reduce": { + "input": [4, 5, 6], + "initialValue": [0], + "in": { + "$concatArrays": ["$$value", {"$map": {"input": [1, 2, 3], "in": "$$this"}}] + }, + } + }, + doc={"_placeholder": 1}, + expected=[0, 1, 2, 3, 1, 2, 3, 1, 2, 3], + msg="$map's $$this should reference $map elements, not $reduce's", + ), + ExpressionTestCase( + "reduce_within_map", + expression={ + "$map": { + "input": [100, 50], + "in": { + "$reduce": { + "input": [25, 25], + "initialValue": 1, + "in": {"$add": ["$$value", "$$this"]}, + } + }, + } + }, + doc={"_placeholder": 1}, + expected=[51, 51], + msg="$reduce's $$value and $$this should be scoped to $reduce", + ), +] + +# Property [System Variables]: $map works with $$ROOT and $$REMOVE. +SYSTEM_VAR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "root_returns_full_doc", + expression={"$map": {"input": "$arr", "in": "$$ROOT"}}, + doc={"_id": 1, "arr": [1, 2]}, + expected=[{"_id": 1, "arr": [1, 2]}, {"_id": 1, "arr": [1, 2]}], + msg="$$ROOT should return root document for each element", + ), + ExpressionTestCase( + "root_field_access", + expression={"$map": {"input": "$arr", "in": "$$ROOT.name"}}, + doc={"_id": 1, "arr": [1, 2], "name": "test"}, + expected=["test", "test"], + msg="$$ROOT.field should access root field for each element", + ), + ExpressionTestCase( + "remove_in_cond_becomes_null", + expression={ + "$map": { + "input": [1, 10, 2, 20], + "in": {"$cond": [{"$gt": ["$$this", 5]}, "$$this", "$$REMOVE"]}, + } + }, + doc={"_placeholder": 1}, + expected=[None, 10, None, 20], + msg="$$REMOVE as array element becomes null, does not remove element", + ), +] + + +ALL_EXPR_TESTS = ( + FIELD_LOOKUP_TESTS + + LET_AND_VARIABLE_TESTS + + NULL_MISSING_EXPR_TESTS + + NESTED_MAP_TESTS + + REDUCE_INTERACTION_TESTS + + SYSTEM_VAR_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_EXPR_TESTS)) +def test_map_field_paths_and_variables(collection, test): + """Test $map with field paths, $let, system variables, and nested composition.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/map/test_map_structure_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/map/test_map_structure_errors.py new file mode 100644 index 000000000..9e34ee16c --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/map/test_map_structure_errors.py @@ -0,0 +1,110 @@ +""" +Structural error tests for $map expression. + +Tests invalid $map argument structure: non-object argument, unknown/misspelled fields, +missing required fields (input, in), and runtime errors in the 'in' expression. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( # noqa: E501 + assert_expression_result, + execute_expression, +) +from documentdb_tests.framework.error_codes import ( + EXPRESSION_NON_OBJECT_ARG_ERROR, + MAP_MISSING_IN_ERROR, + MAP_MISSING_INPUT_ERROR, + MAP_UNKNOWN_FIELD_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Object Argument]: $map rejects non-object arguments. +NON_OBJECT_ARG_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_arg", + expression={"$map": None}, + error_code=EXPRESSION_NON_OBJECT_ARG_ERROR, + msg="$map null arg should error", + ), + ExpressionTestCase( + "int_arg", + expression={"$map": 1}, + error_code=EXPRESSION_NON_OBJECT_ARG_ERROR, + msg="$map int arg should error", + ), + ExpressionTestCase( + "string_arg", + expression={"$map": "string"}, + error_code=EXPRESSION_NON_OBJECT_ARG_ERROR, + msg="$map string arg should error", + ), + ExpressionTestCase( + "array_arg", + expression={"$map": [1, 2]}, + error_code=EXPRESSION_NON_OBJECT_ARG_ERROR, + msg="$map array arg should error", + ), + ExpressionTestCase( + "bool_arg", + expression={"$map": True}, + error_code=EXPRESSION_NON_OBJECT_ARG_ERROR, + msg="$map bool arg should error", + ), +] + +# Property [Unknown Fields]: $map rejects unknown fields in the argument. +UNKNOWN_FIELD_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "extra_unknown", + expression={"$map": {"input": [1], "in": "$$this", "unknown": 1}}, + error_code=MAP_UNKNOWN_FIELD_ERROR, + msg="$map extra unknown field should error", + ), + ExpressionTestCase( + "misspelled_inputs", + expression={"$map": {"inputs": [1], "in": "$$this"}}, + error_code=MAP_UNKNOWN_FIELD_ERROR, + msg="$map misspelled 'inputs' should error", + ), +] + +# Property [Required Fields]: $map requires the input and in fields. +MISSING_REQUIRED_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "missing_input", + expression={"$map": {"in": "$$this"}}, + error_code=MAP_MISSING_INPUT_ERROR, + msg="$map missing input should error", + ), + ExpressionTestCase( + "missing_in", + expression={"$map": {"input": [1, 2, 3]}}, + error_code=MAP_MISSING_IN_ERROR, + msg="$map missing in should error", + ), + ExpressionTestCase( + "missing_in_with_as", + expression={"$map": {"input": [1, 2, 3], "as": "x"}}, + error_code=MAP_MISSING_IN_ERROR, + msg="$map missing in with as should error", + ), + ExpressionTestCase( + "empty_object", + expression={"$map": {}}, + error_code=MAP_MISSING_INPUT_ERROR, + msg="$map empty object should error", + ), +] + +ALL_STRUCTURE_TESTS = NON_OBJECT_ARG_TESTS + UNKNOWN_FIELD_TESTS + MISSING_REQUIRED_TESTS + + +@pytest.mark.parametrize("test", pytest_params(ALL_STRUCTURE_TESTS)) +def test_map_structure_error(collection, test): + """Test $map argument structure validation.""" + result = execute_expression(collection, test.expression) + assert_expression_result(result, error_code=test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/map/test_smoke_expression_map.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/map/test_smoke_map.py similarity index 92% rename from documentdb_tests/compatibility/tests/core/operator/expressions/array/map/test_smoke_expression_map.py rename to documentdb_tests/compatibility/tests/core/operator/expressions/array/map/test_smoke_map.py index a5bf7ae58..d3ef08577 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/array/map/test_smoke_expression_map.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/map/test_smoke_map.py @@ -38,4 +38,4 @@ def test_smoke_expression_map(collection): ) expected = [{"_id": 1, "doubled": [2, 4, 6]}, {"_id": 2, "doubled": [8, 10, 12]}] - assertSuccess(result, expected, msg="Should support $map expression") + assertSuccess(result, expected, "Should support $map expression", ignore_doc_order=True) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/range/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/range/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/range/test_range_boundary.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/range/test_range_boundary.py new file mode 100644 index 000000000..b50d89e2a --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/range/test_range_boundary.py @@ -0,0 +1,87 @@ +""" +Boundary value tests for $range expression. + +Tests INT32 boundary value handling for start, end, step. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( # noqa: E501 + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import INT32_MAX, INT32_MAX_MINUS_1, INT32_MIN + +# Property [INT32 Boundaries]: $range works at INT32 boundary values. +INT32_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int32_max_eq", + doc={"start": INT32_MAX, "end": INT32_MAX}, + expression={"$range": ["$start", "$end"]}, + expected=[], + msg="$range INT32_MAX == INT32_MAX should be empty", + ), + ExpressionTestCase( + "int32_min_eq", + doc={"start": INT32_MIN, "end": INT32_MIN}, + expression={"$range": ["$start", "$end"]}, + expected=[], + msg="$range INT32_MIN == INT32_MIN should be empty", + ), + ExpressionTestCase( + "int32_max_minus1", + doc={"start": INT32_MAX_MINUS_1, "end": INT32_MAX}, + expression={"$range": ["$start", "$end"]}, + expected=[INT32_MAX_MINUS_1], + msg="$range INT32_MAX-1 to INT32_MAX should produce single element", + ), + ExpressionTestCase( + "int32_min_to_plus3", + doc={"start": INT32_MIN, "end": INT32_MIN + 3}, + expression={"$range": ["$start", "$end"]}, + expected=[INT32_MIN, INT32_MIN + 1, INT32_MIN + 2], + msg="$range INT32_MIN to INT32_MIN+3", + ), + ExpressionTestCase( + "step_int32_max", + doc={"start": 0, "end": INT32_MAX, "step": INT32_MAX}, + expression={"$range": ["$start", "$end", "$step"]}, + expected=[0], + msg="$range step INT32_MAX should produce single element", + ), + ExpressionTestCase( + "near_int32_max", + doc={"start": INT32_MAX - 7, "end": INT32_MAX}, + expression={"$range": ["$start", "$end"]}, + expected=[ + INT32_MAX - 7, + INT32_MAX - 6, + INT32_MAX - 5, + INT32_MAX - 4, + INT32_MAX - 3, + INT32_MAX - 2, + INT32_MAX - 1, + ], + msg="$range near INT32_MAX range", + ), + ExpressionTestCase( + "cross_int32_boundary", + doc={"start": INT32_MIN + 1, "end": INT32_MAX, "step": INT32_MAX}, + expression={"$range": ["$start", "$end", "$step"]}, + expected=[INT32_MIN + 1, 0], + msg="$range cross INT32 boundary with large step", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(INT32_BOUNDARY_TESTS)) +def test_range_int32_boundary(collection, test): + """Test $range with INT32 boundary values for start, end, step.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/range/test_range_core_behavior.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/range/test_range_core_behavior.py new file mode 100644 index 000000000..df99838b3 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/range/test_range_core_behavior.py @@ -0,0 +1,226 @@ +""" +Core behavior tests for $range expression. + +Tests generating integer sequences with various start, end, step values, +negative steps (descending), and empty results. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( # noqa: E501 + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Basic Ascending]: $range generates ascending integer sequences. +BASIC_ASC_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "zero_to_five", + doc={"start": 0, "end": 5}, + expression={"$range": ["$start", "$end"]}, + expected=[0, 1, 2, 3, 4], + msg="$range should generate 0..4", + ), + ExpressionTestCase( + "one_to_four", + doc={"start": 1, "end": 4}, + expression={"$range": ["$start", "$end"]}, + expected=[1, 2, 3], + msg="$range should generate 1..3", + ), + ExpressionTestCase( + "negative_range", + doc={"start": -5, "end": -1}, + expression={"$range": ["$start", "$end"]}, + expected=[-5, -4, -3, -2], + msg="$range should generate -5..-2", + ), + ExpressionTestCase( + "start_equals_end", + doc={"start": 5, "end": 5}, + expression={"$range": ["$start", "$end"]}, + expected=[], + msg="$range should return empty when start equals end", + ), + ExpressionTestCase( + "start_greater_than_end", + doc={"start": 10, "end": 5}, + expression={"$range": ["$start", "$end"]}, + expected=[], + msg="$range should return empty when start > end with default step", + ), +] + +# Property [Custom Step]: $range respects custom step values. +STEP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "step_two", + doc={"start": 0, "end": 10, "step": 2}, + expression={"$range": ["$start", "$end", "$step"]}, + expected=[0, 2, 4, 6, 8], + msg="$range should generate with step 2", + ), + ExpressionTestCase( + "step_three", + doc={"start": 0, "end": 10, "step": 3}, + expression={"$range": ["$start", "$end", "$step"]}, + expected=[0, 3, 6, 9], + msg="$range should generate with step 3", + ), + ExpressionTestCase( + "step_five", + doc={"start": 0, "end": 20, "step": 5}, + expression={"$range": ["$start", "$end", "$step"]}, + expected=[0, 5, 10, 15], + msg="$range should generate with step 5", + ), + ExpressionTestCase( + "step_one_explicit", + doc={"start": 0, "end": 3, "step": 1}, + expression={"$range": ["$start", "$end", "$step"]}, + expected=[0, 1, 2], + msg="$range explicit step 1 same as default", + ), + ExpressionTestCase( + "step_overshoots", + doc={"start": 0, "end": 5, "step": 3}, + expression={"$range": ["$start", "$end", "$step"]}, + expected=[0, 3], + msg="$range should stop when step overshoots end", + ), + ExpressionTestCase( + "step_exactly_reaches", + doc={"start": 0, "end": 6, "step": 2}, + expression={"$range": ["$start", "$end", "$step"]}, + expected=[0, 2, 4], + msg="$range end is exclusive even when step exactly reaches it", + ), + ExpressionTestCase( + "start_nonzero", + doc={"start": 5, "end": 15}, + expression={"$range": ["$start", "$end"]}, + expected=[5, 6, 7, 8, 9, 10, 11, 12, 13, 14], + msg="$range should work with nonzero start", + ), + ExpressionTestCase( + "step_4", + doc={"start": 5, "end": 15, "step": 4}, + expression={"$range": ["$start", "$end", "$step"]}, + expected=[5, 9, 13], + msg="$range should work with step 4", + ), +] + +# Property [Negative Step]: $range generates descending sequences with negative step. +NEGATIVE_STEP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "descending_basic", + doc={"start": 5, "end": 0, "step": -1}, + expression={"$range": ["$start", "$end", "$step"]}, + expected=[5, 4, 3, 2, 1], + msg="$range should generate descending 5..1", + ), + ExpressionTestCase( + "descending_step_two", + doc={"start": 10, "end": 0, "step": -2}, + expression={"$range": ["$start", "$end", "$step"]}, + expected=[10, 8, 6, 4, 2], + msg="$range should generate descending with step -2", + ), + ExpressionTestCase( + "descending_negative_range", + doc={"start": -1, "end": -5, "step": -1}, + expression={"$range": ["$start", "$end", "$step"]}, + expected=[-1, -2, -3, -4], + msg="$range should generate descending in negative range", + ), + ExpressionTestCase( + "descending_start_equals_end", + doc={"start": 5, "end": 5, "step": -1}, + expression={"$range": ["$start", "$end", "$step"]}, + expected=[], + msg="$range should return empty when start equals end with negative step", + ), + ExpressionTestCase( + "descending_wrong_direction", + doc={"start": 0, "end": 5, "step": -1}, + expression={"$range": ["$start", "$end", "$step"]}, + expected=[], + msg="$range should return empty when step direction mismatches", + ), + ExpressionTestCase( + "descending_step_neg3", + doc={"start": 10, "end": 0, "step": -3}, + expression={"$range": ["$start", "$end", "$step"]}, + expected=[10, 7, 4, 1], + msg="$range should generate descending with step -3", + ), + ExpressionTestCase( + "descending_past_zero", + doc={"start": 5, "end": -1, "step": -1}, + expression={"$range": ["$start", "$end", "$step"]}, + expected=[5, 4, 3, 2, 1, 0], + msg="$range should descend past zero", + ), +] + +# Property [Empty Result]: $range returns empty when start/end/step produce no elements. +EMPTY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "ascending_wrong_direction", + doc={"start": 5, "end": 0, "step": 1}, + expression={"$range": ["$start", "$end", "$step"]}, + expected=[], + msg="$range should return empty when ascending step but start > end", + ), + ExpressionTestCase( + "zero_zero_pos_step", + doc={"start": 0, "end": 0, "step": 1}, + expression={"$range": ["$start", "$end", "$step"]}, + expected=[], + msg="$range 0 to 0 step 1 should be empty", + ), + ExpressionTestCase( + "zero_zero_neg_step", + doc={"start": 0, "end": 0, "step": -1}, + expression={"$range": ["$start", "$end", "$step"]}, + expected=[], + msg="$range 0 to 0 step -1 should be empty", + ), + ExpressionTestCase( + "neg_equal", + doc={"start": -1, "end": -1}, + expression={"$range": ["$start", "$end"]}, + expected=[], + msg="$range -1 to -1 should be empty", + ), + ExpressionTestCase( + "large_equal", + doc={"start": 1000000, "end": 1000000}, + expression={"$range": ["$start", "$end"]}, + expected=[], + msg="$range large equal start/end should be empty", + ), + ExpressionTestCase( + "neg_mismatch", + doc={"start": -1, "end": -5, "step": 1}, + expression={"$range": ["$start", "$end", "$step"]}, + expected=[], + msg="$range negative range with positive step should be empty", + ), +] + +ALL_TESTS = BASIC_ASC_TESTS + STEP_TESTS + NEGATIVE_STEP_TESTS + EMPTY_TESTS + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_range_core_behavior(collection, test): + """Test $range core behavior: ascending, steps, descending, empty results.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/range/test_range_expressions.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/range/test_range_expressions.py new file mode 100644 index 000000000..b39f4cc31 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/range/test_range_expressions.py @@ -0,0 +1,156 @@ +""" +Expression and field path tests for $range expression. + +Tests field path lookups, composite paths, system variables, +and null/missing behavior. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( # noqa: E501 + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + RANGE_END_NOT_NUMERIC_ERROR, + RANGE_START_NOT_INT32_ERROR, + RANGE_STEP_NOT_NUMERIC_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Field Path Resolution]: $range resolves nested and composite field paths. +# Property [Field Lookup]: $range resolves field paths in expressions. +FIELD_LOOKUP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_field_path", + expression={"$range": ["$a.start", "$a.end"]}, + doc={"a": {"start": 0, "end": 3}}, + expected=[0, 1, 2], + msg="$range should resolve nested field paths", + ), + ExpressionTestCase( + "deeply_nested_field", + expression={"$range": ["$a.b.start", "$a.b.end"]}, + doc={"a": {"b": {"start": 1, "end": 4}}}, + expected=[1, 2, 3], + msg="$range should resolve deeply nested field paths", + ), + ExpressionTestCase( + "nested_expr_start_end", + expression={"$range": [{"$add": [1, 2]}, {"$multiply": [2, 5]}]}, + doc={"_placeholder": 1}, + expected=[3, 4, 5, 6, 7, 8, 9], + msg="$range should support nested expressions as start and end", + ), +] + +# Property [Variables]: $range works with $let and system variables. +LET_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "let_variable", + expression={ + "$let": { + "vars": {"s": "$start", "e": "$end"}, + "in": {"$range": ["$$s", "$$e"]}, + } + }, + doc={"start": 0, "end": 3}, + expected=[0, 1, 2], + msg="$range should work with $let variables", + ), + ExpressionTestCase( + "root_variable", + expression={"$range": ["$$ROOT.start", "$$ROOT.end"]}, + doc={"_id": 1, "start": 0, "end": 3}, + expected=[0, 1, 2], + msg="$range should work with $$ROOT", + ), + ExpressionTestCase( + "current_variable", + expression={"$range": ["$$CURRENT.start", "$$CURRENT.end"]}, + doc={"_id": 2, "start": 0, "end": 3}, + expected=[0, 1, 2], + msg="$$CURRENT should be equivalent to field path", + ), + ExpressionTestCase( + "remove_variable", + expression={"$range": ["$$REMOVE", 5]}, + doc={"x": 1}, + error_code=RANGE_START_NOT_INT32_ERROR, + msg="$$REMOVE should error like missing field", + ), +] + +# Property [Null/Missing Fields]: $range errors on null and missing field paths. +NULL_MISSING_EXPR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "missing_start_field", + expression={"$range": ["$nonexistent", 5]}, + doc={"other": 1}, + error_code=RANGE_START_NOT_INT32_ERROR, + msg="$range missing start field should error", + ), + ExpressionTestCase( + "missing_end_field", + expression={"$range": [0, "$nonexistent"]}, + doc={"other": 1}, + error_code=RANGE_END_NOT_NUMERIC_ERROR, + msg="$range missing end field should error", + ), + ExpressionTestCase( + "null_start_field", + expression={"$range": ["$a", 5]}, + doc={"a": None}, + error_code=RANGE_START_NOT_INT32_ERROR, + msg="$range null start field should error", + ), + ExpressionTestCase( + "null_end_field", + expression={"$range": [0, "$a"]}, + doc={"a": None}, + error_code=RANGE_END_NOT_NUMERIC_ERROR, + msg="$range null end field should error", + ), + ExpressionTestCase( + "null_step_field", + expression={"$range": [0, 5, "$a"]}, + doc={"a": None}, + error_code=RANGE_STEP_NOT_NUMERIC_ERROR, + msg="$range null step field should error", + ), + ExpressionTestCase( + "all_missing_fields", + expression={"$range": ["$a", "$b"]}, + doc={"_placeholder": 1}, + error_code=RANGE_START_NOT_INT32_ERROR, + msg="$range all missing should error on start first", + ), + ExpressionTestCase( + "composite_array_path_error", + expression={"$range": ["$a.b", 5]}, + doc={"a": [{"b": 0}, {"b": 5}]}, + error_code=RANGE_START_NOT_INT32_ERROR, + msg="$range composite array path should error", + ), + ExpressionTestCase( + "array_index_path_error", + expression={"$range": ["$a.0", "$a.1", "$a.2"]}, + doc={"a": [0, 5, 2]}, + error_code=RANGE_START_NOT_INT32_ERROR, + msg="$range array index path should error in expression context", + ), +] + +ALL_EXPR_TESTS = FIELD_LOOKUP_TESTS + LET_TESTS + NULL_MISSING_EXPR_TESTS + + +@pytest.mark.parametrize("test", pytest_params(ALL_EXPR_TESTS)) +def test_range_field_paths_and_variables(collection, test): + """Test $range with field paths, $let, system variables, and null/missing errors.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/range/test_range_numeric_acceptance.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/range/test_range_numeric_acceptance.py new file mode 100644 index 000000000..5e4a1b540 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/range/test_range_numeric_acceptance.py @@ -0,0 +1,230 @@ +""" +Numeric type acceptance tests for $range expression. + +Tests Int64, whole doubles, whole Decimal128, single-element results, +negative number ranges, large ranges, and negative zero handling. +""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( # noqa: E501 + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ZERO, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_ZERO, + INT64_ZERO, +) + +# Property [Numeric Type Acceptance]: $range accepts Int64, whole doubles, and whole Decimal128. +NUMERIC_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int64_args", + doc={"start": INT64_ZERO, "end": Int64(3)}, + expression={"$range": ["$start", "$end"]}, + expected=[0, 1, 2], + msg="$range should accept Int64 arguments", + ), + ExpressionTestCase( + "whole_double_args", + doc={"start": DOUBLE_ZERO, "end": 3.0}, + expression={"$range": ["$start", "$end"]}, + expected=[0, 1, 2], + msg="$range should accept whole-number double arguments", + ), + ExpressionTestCase( + "whole_decimal128_args", + doc={"start": DECIMAL128_ZERO, "end": Decimal128("3")}, + expression={"$range": ["$start", "$end"]}, + expected=[0, 1, 2], + msg="$range should accept whole-number Decimal128 arguments", + ), + ExpressionTestCase( + "int64_step", + doc={"start": 0, "end": 6, "step": Int64(2)}, + expression={"$range": ["$start", "$end", "$step"]}, + expected=[0, 2, 4], + msg="$range should accept Int64 step", + ), + ExpressionTestCase( + "whole_double_step", + doc={"start": 0, "end": 6, "step": 2.0}, + expression={"$range": ["$start", "$end", "$step"]}, + expected=[0, 2, 4], + msg="$range should accept whole-number double step", + ), + ExpressionTestCase( + "whole_decimal128_step", + doc={"start": 0, "end": 6, "step": Decimal128("2")}, + expression={"$range": ["$start", "$end", "$step"]}, + expected=[0, 2, 4], + msg="$range should accept whole-number Decimal128 step", + ), + ExpressionTestCase( + "decimal128_trailing_zero_start", + doc={"start": Decimal128("0.0"), "end": 3}, + expression={"$range": ["$start", "$end"]}, + expected=[0, 1, 2], + msg="$range should accept Decimal128 0.0 as start", + ), + ExpressionTestCase( + "decimal128_trailing_zero_end", + doc={"start": 0, "end": Decimal128("3.0")}, + expression={"$range": ["$start", "$end"]}, + expected=[0, 1, 2], + msg="$range should accept Decimal128 3.0 as end", + ), + ExpressionTestCase( + "decimal128_trailing_zero_both", + doc={"start": Decimal128("0.0"), "end": Decimal128("3.0")}, + expression={"$range": ["$start", "$end"]}, + expected=[0, 1, 2], + msg="$range should accept Decimal128 0.0 start and 3.0 end", + ), +] + +# Property [Single Element]: $range produces single-element arrays. +SINGLE_ELEMENT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "single_element", + doc={"start": 0, "end": 1}, + expression={"$range": ["$start", "$end"]}, + expected=[0], + msg="$range should return single element", + ), + ExpressionTestCase( + "desc_single", + doc={"start": 1, "end": 0, "step": -1}, + expression={"$range": ["$start", "$end", "$step"]}, + expected=[1], + msg="$range descending single element", + ), + ExpressionTestCase( + "step_eq_range", + doc={"start": 0, "end": 5, "step": 5}, + expression={"$range": ["$start", "$end", "$step"]}, + expected=[0], + msg="$range step equal to range should produce single element", + ), + ExpressionTestCase( + "step_gt_range", + doc={"start": 0, "end": 5, "step": 10}, + expression={"$range": ["$start", "$end", "$step"]}, + expected=[0], + msg="$range step greater than range should produce single element", + ), +] + +# Property [Negative Numbers]: $range handles negative start, end, and crossing zero. +NEGATIVE_RANGE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "neg_to_zero", + doc={"start": -5, "end": 0}, + expression={"$range": ["$start", "$end"]}, + expected=[-5, -4, -3, -2, -1], + msg="$range negative start to zero ascending", + ), + ExpressionTestCase( + "zero_to_neg", + doc={"start": 0, "end": -5, "step": -1}, + expression={"$range": ["$start", "$end", "$step"]}, + expected=[0, -1, -2, -3, -4], + msg="$range zero to negative descending", + ), + ExpressionTestCase( + "cross_zero_asc", + doc={"start": -3, "end": 3}, + expression={"$range": ["$start", "$end"]}, + expected=[-3, -2, -1, 0, 1, 2], + msg="$range crossing zero ascending", + ), + ExpressionTestCase( + "cross_zero_desc", + doc={"start": 3, "end": -3, "step": -1}, + expression={"$range": ["$start", "$end", "$step"]}, + expected=[3, 2, 1, 0, -1, -2], + msg="$range crossing zero descending", + ), + ExpressionTestCase( + "neg_desc_step2", + doc={"start": -10, "end": -20, "step": -2}, + expression={"$range": ["$start", "$end", "$step"]}, + expected=[-10, -12, -14, -16, -18], + msg="$range negative descending with step -2", + ), + ExpressionTestCase( + "neg_asc_step2", + doc={"start": -10, "end": -1, "step": 2}, + expression={"$range": ["$start", "$end", "$step"]}, + expected=[-10, -8, -6, -4, -2], + msg="$range negative ascending with step 2", + ), +] + +# Property [Large Range]: $range generates large sequences. +LARGE_RANGE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "large_range", + doc={"start": 0, "end": 1000}, + expression={"$range": ["$start", "$end"]}, + expected=list(range(1000)), + msg="$range should generate large range", + ), +] + +# Property [Negative Zero]: $range treats negative zero as zero. +NEG_ZERO_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "neg_zero_double_start", + doc={"start": DOUBLE_NEGATIVE_ZERO, "end": 5}, + expression={"$range": ["$start", "$end"]}, + expected=[0, 1, 2, 3, 4], + msg="$range negative zero double start treated as 0", + ), + ExpressionTestCase( + "neg_zero_decimal_start", + doc={"start": DECIMAL128_NEGATIVE_ZERO, "end": 5}, + expression={"$range": ["$start", "$end"]}, + expected=[0, 1, 2, 3, 4], + msg="$range negative zero Decimal128 start treated as 0", + ), + ExpressionTestCase( + "neg_zero_double_end", + doc={"start": 0, "end": DOUBLE_NEGATIVE_ZERO}, + expression={"$range": ["$start", "$end"]}, + expected=[], + msg="$range negative zero double end treated as 0", + ), + ExpressionTestCase( + "neg_zero_decimal_end", + doc={"start": 0, "end": DECIMAL128_NEGATIVE_ZERO}, + expression={"$range": ["$start", "$end"]}, + expected=[], + msg="$range negative zero Decimal128 end treated as 0", + ), +] + +ALL_TESTS = ( + NUMERIC_TYPE_TESTS + + SINGLE_ELEMENT_TESTS + + NEGATIVE_RANGE_TESTS + + LARGE_RANGE_TESTS + + NEG_ZERO_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_range_numeric_acceptance(collection, test): + """Test $range accepts Int64, whole doubles, whole Decimal128, and negative zero.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/range/test_range_type_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/range/test_range_type_errors.py new file mode 100644 index 000000000..34474fda6 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/range/test_range_type_errors.py @@ -0,0 +1,292 @@ +""" +Type error tests for $range expression. + +Tests non-numeric types for start, end, and step positions. +""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( # noqa: E501 + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + RANGE_END_NOT_NUMERIC_ERROR, + RANGE_START_NOT_INT32_ERROR, + RANGE_STEP_NOT_NUMERIC_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Non-Numeric Start]: $range rejects non-numeric types for start. +NON_NUMERIC_START_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "string_start", + doc={"start": "hello", "end": 5}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_START_NOT_INT32_ERROR, + msg="$range should reject string start", + ), + ExpressionTestCase( + "bool_start", + doc={"start": True, "end": 5}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_START_NOT_INT32_ERROR, + msg="$range should reject bool start", + ), + ExpressionTestCase( + "null_start", + doc={"start": None, "end": 5}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_START_NOT_INT32_ERROR, + msg="$range should reject null start", + ), + ExpressionTestCase( + "object_start", + doc={"start": {"a": 1}, "end": 5}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_START_NOT_INT32_ERROR, + msg="$range should reject object start", + ), + ExpressionTestCase( + "array_start", + doc={"start": [1], "end": 5}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_START_NOT_INT32_ERROR, + msg="$range should reject array start", + ), + ExpressionTestCase( + "objectid_start", + doc={"start": ObjectId(), "end": 5}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_START_NOT_INT32_ERROR, + msg="$range should reject objectid start", + ), + ExpressionTestCase( + "datetime_start", + doc={"start": datetime(2024, 1, 1, tzinfo=timezone.utc), "end": 5}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_START_NOT_INT32_ERROR, + msg="$range should reject datetime start", + ), + ExpressionTestCase( + "binary_start", + doc={"start": Binary(b"x", 0), "end": 5}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_START_NOT_INT32_ERROR, + msg="$range should reject binary start", + ), + ExpressionTestCase( + "regex_start", + doc={"start": Regex("x"), "end": 5}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_START_NOT_INT32_ERROR, + msg="$range should reject regex start", + ), + ExpressionTestCase( + "maxkey_start", + doc={"start": MaxKey(), "end": 5}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_START_NOT_INT32_ERROR, + msg="$range should reject maxkey start", + ), + ExpressionTestCase( + "minkey_start", + doc={"start": MinKey(), "end": 5}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_START_NOT_INT32_ERROR, + msg="$range should reject minkey start", + ), + ExpressionTestCase( + "timestamp_start", + doc={"start": Timestamp(0, 0), "end": 5}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_START_NOT_INT32_ERROR, + msg="$range should reject timestamp start", + ), +] + +# Property [Non-Numeric End]: $range rejects non-numeric types for end. +NON_NUMERIC_END_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "string_end", + doc={"start": 0, "end": "hello"}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_END_NOT_NUMERIC_ERROR, + msg="$range should reject string end", + ), + ExpressionTestCase( + "bool_end", + doc={"start": 0, "end": True}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_END_NOT_NUMERIC_ERROR, + msg="$range should reject bool end", + ), + ExpressionTestCase( + "null_end", + doc={"start": 0, "end": None}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_END_NOT_NUMERIC_ERROR, + msg="$range should reject null end", + ), + ExpressionTestCase( + "object_end", + doc={"start": 0, "end": {"a": 1}}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_END_NOT_NUMERIC_ERROR, + msg="$range should reject object end", + ), + ExpressionTestCase( + "array_end", + doc={"start": 0, "end": [1]}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_END_NOT_NUMERIC_ERROR, + msg="$range should reject array end", + ), + ExpressionTestCase( + "objectid_end", + doc={"start": 0, "end": ObjectId()}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_END_NOT_NUMERIC_ERROR, + msg="$range should reject objectid end", + ), + ExpressionTestCase( + "datetime_end", + doc={"start": 0, "end": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_END_NOT_NUMERIC_ERROR, + msg="$range should reject datetime end", + ), + ExpressionTestCase( + "binary_end", + doc={"start": 0, "end": Binary(b"x", 0)}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_END_NOT_NUMERIC_ERROR, + msg="$range should reject binary end", + ), + ExpressionTestCase( + "regex_end", + doc={"start": 0, "end": Regex("x")}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_END_NOT_NUMERIC_ERROR, + msg="$range should reject regex end", + ), + ExpressionTestCase( + "maxkey_end", + doc={"start": 0, "end": MaxKey()}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_END_NOT_NUMERIC_ERROR, + msg="$range should reject maxkey end", + ), + ExpressionTestCase( + "minkey_end", + doc={"start": 0, "end": MinKey()}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_END_NOT_NUMERIC_ERROR, + msg="$range should reject minkey end", + ), + ExpressionTestCase( + "timestamp_end", + doc={"start": 0, "end": Timestamp(0, 0)}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_END_NOT_NUMERIC_ERROR, + msg="$range should reject timestamp end", + ), +] + +# Property [Non-Numeric Step]: $range rejects non-numeric types for step. +NON_NUMERIC_STEP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "string_step", + doc={"start": 0, "end": 5, "step": "bad"}, + expression={"$range": ["$start", "$end", "$step"]}, + error_code=RANGE_STEP_NOT_NUMERIC_ERROR, + msg="$range should reject string step", + ), + ExpressionTestCase( + "bool_step", + doc={"start": 0, "end": 5, "step": True}, + expression={"$range": ["$start", "$end", "$step"]}, + error_code=RANGE_STEP_NOT_NUMERIC_ERROR, + msg="$range should reject bool step", + ), + ExpressionTestCase( + "object_step", + doc={"start": 0, "end": 5, "step": {"a": 1}}, + expression={"$range": ["$start", "$end", "$step"]}, + error_code=RANGE_STEP_NOT_NUMERIC_ERROR, + msg="$range should reject object step", + ), + ExpressionTestCase( + "array_step", + doc={"start": 0, "end": 5, "step": [1]}, + expression={"$range": ["$start", "$end", "$step"]}, + error_code=RANGE_STEP_NOT_NUMERIC_ERROR, + msg="$range should reject array step", + ), + ExpressionTestCase( + "objectid_step", + doc={"start": 0, "end": 5, "step": ObjectId()}, + expression={"$range": ["$start", "$end", "$step"]}, + error_code=RANGE_STEP_NOT_NUMERIC_ERROR, + msg="$range should reject objectid step", + ), + ExpressionTestCase( + "datetime_step", + doc={"start": 0, "end": 5, "step": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + expression={"$range": ["$start", "$end", "$step"]}, + error_code=RANGE_STEP_NOT_NUMERIC_ERROR, + msg="$range should reject datetime step", + ), + ExpressionTestCase( + "binary_step", + doc={"start": 0, "end": 5, "step": Binary(b"x", 0)}, + expression={"$range": ["$start", "$end", "$step"]}, + error_code=RANGE_STEP_NOT_NUMERIC_ERROR, + msg="$range should reject binary step", + ), + ExpressionTestCase( + "regex_step", + doc={"start": 0, "end": 5, "step": Regex("x")}, + expression={"$range": ["$start", "$end", "$step"]}, + error_code=RANGE_STEP_NOT_NUMERIC_ERROR, + msg="$range should reject regex step", + ), + ExpressionTestCase( + "maxkey_step", + doc={"start": 0, "end": 5, "step": MaxKey()}, + expression={"$range": ["$start", "$end", "$step"]}, + error_code=RANGE_STEP_NOT_NUMERIC_ERROR, + msg="$range should reject maxkey step", + ), + ExpressionTestCase( + "minkey_step", + doc={"start": 0, "end": 5, "step": MinKey()}, + expression={"$range": ["$start", "$end", "$step"]}, + error_code=RANGE_STEP_NOT_NUMERIC_ERROR, + msg="$range should reject minkey step", + ), + ExpressionTestCase( + "timestamp_step", + doc={"start": 0, "end": 5, "step": Timestamp(0, 0)}, + expression={"$range": ["$start", "$end", "$step"]}, + error_code=RANGE_STEP_NOT_NUMERIC_ERROR, + msg="$range should reject timestamp step", + ), +] + +ALL_TESTS = NON_NUMERIC_START_TESTS + NON_NUMERIC_END_TESTS + NON_NUMERIC_STEP_TESTS + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_range_type_error(collection, test): + """Test $range error with non-numeric types for start, end, step.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/range/test_range_value_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/range/test_range_value_errors.py new file mode 100644 index 000000000..a3e674452 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/range/test_range_value_errors.py @@ -0,0 +1,381 @@ +""" +Value error tests for $range expression. + +Tests non-integral values, special numeric values (NaN, Infinity), +step zero, out-of-int32-range values, and wrong arity. +""" + +import pytest +from bson import Decimal128 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( # noqa: E501 + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + EXPRESSION_ARITY_ERROR, + RANGE_END_NOT_INT32_ERROR, + RANGE_START_NOT_INTEGRAL_ERROR, + RANGE_STEP_NOT_INT32_ERROR, + RANGE_STEP_ZERO_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_HALF, + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_NAN, + DECIMAL128_NEGATIVE_ONE_AND_HALF, + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ONE_AND_HALF, + DECIMAL128_ZERO, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + INT32_OVERFLOW, + INT32_UNDERFLOW, + INT64_MAX, + INT64_MIN, + INT64_ZERO, +) + +# Property [Non-Integral Start]: $range rejects fractional numeric values for start. +NON_INTEGRAL_START_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "fractional_start", + doc={"start": 1.5, "end": 5}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_START_NOT_INTEGRAL_ERROR, + msg="$range should reject fractional start", + ), + ExpressionTestCase( + "decimal128_fractional_start", + doc={"start": DECIMAL128_HALF, "end": 5}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_START_NOT_INTEGRAL_ERROR, + msg="$range should reject fractional Decimal128 start", + ), + ExpressionTestCase( + "negative_fractional_start", + doc={"start": -1.5, "end": 5}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_START_NOT_INTEGRAL_ERROR, + msg="$range should reject negative fractional start", + ), + ExpressionTestCase( + "decimal128_negative_fractional_start", + doc={"start": DECIMAL128_NEGATIVE_ONE_AND_HALF, "end": 5}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_START_NOT_INTEGRAL_ERROR, + msg="$range should reject negative fractional Decimal128 start", + ), + ExpressionTestCase( + "decimal128_negative_nan_start", + doc={"start": DECIMAL128_NEGATIVE_NAN, "end": 5}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_START_NOT_INTEGRAL_ERROR, + msg="$range should reject Decimal128 -NaN start", + ), +] + +# Property [Non-Integral End]: $range rejects fractional numeric values for end. +NON_INTEGRAL_END_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "fractional_end", + doc={"start": 0, "end": 5.5}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_END_NOT_INT32_ERROR, + msg="$range should reject fractional end", + ), + ExpressionTestCase( + "decimal128_fractional_end", + doc={"start": 0, "end": Decimal128("5.5")}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_END_NOT_INT32_ERROR, + msg="$range should reject fractional Decimal128 end", + ), + ExpressionTestCase( + "negative_fractional_end", + doc={"start": 0, "end": -1.5}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_END_NOT_INT32_ERROR, + msg="$range should reject negative fractional end", + ), + ExpressionTestCase( + "decimal128_negative_fractional_end", + doc={"start": 0, "end": DECIMAL128_NEGATIVE_ONE_AND_HALF}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_END_NOT_INT32_ERROR, + msg="$range should reject negative fractional Decimal128 end", + ), +] + +# Property [Non-Integral Step]: $range rejects fractional numeric values for step. +NON_INTEGRAL_STEP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "fractional_step", + doc={"start": 0, "end": 10, "step": 1.5}, + expression={"$range": ["$start", "$end", "$step"]}, + error_code=RANGE_STEP_NOT_INT32_ERROR, + msg="$range should reject fractional step", + ), + ExpressionTestCase( + "decimal128_fractional_step", + doc={"start": 0, "end": 10, "step": DECIMAL128_ONE_AND_HALF}, + expression={"$range": ["$start", "$end", "$step"]}, + error_code=RANGE_STEP_NOT_INT32_ERROR, + msg="$range should reject fractional Decimal128 step", + ), + ExpressionTestCase( + "negative_fractional_step", + doc={"start": 10, "end": 0, "step": -1.5}, + expression={"$range": ["$start", "$end", "$step"]}, + error_code=RANGE_STEP_NOT_INT32_ERROR, + msg="$range should reject negative fractional step", + ), + ExpressionTestCase( + "decimal128_negative_fractional_step", + doc={"start": 10, "end": 0, "step": DECIMAL128_NEGATIVE_ONE_AND_HALF}, + expression={"$range": ["$start", "$end", "$step"]}, + error_code=RANGE_STEP_NOT_INT32_ERROR, + msg="$range should reject negative fractional Decimal128 step", + ), +] + +# Property [Special Numerics]: $range rejects NaN and Infinity values. +SPECIAL_NUMERIC_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nan_start", + doc={"start": FLOAT_NAN, "end": 5}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_START_NOT_INTEGRAL_ERROR, + msg="$range should reject NaN start", + ), + ExpressionTestCase( + "inf_start", + doc={"start": FLOAT_INFINITY, "end": 5}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_START_NOT_INTEGRAL_ERROR, + msg="$range should reject Infinity start", + ), + ExpressionTestCase( + "neg_inf_start", + doc={"start": FLOAT_NEGATIVE_INFINITY, "end": 5}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_START_NOT_INTEGRAL_ERROR, + msg="$range should reject -Infinity start", + ), + ExpressionTestCase( + "nan_end", + doc={"start": 0, "end": FLOAT_NAN}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_END_NOT_INT32_ERROR, + msg="$range should reject NaN end", + ), + ExpressionTestCase( + "inf_end", + doc={"start": 0, "end": FLOAT_INFINITY}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_END_NOT_INT32_ERROR, + msg="$range should reject Infinity end", + ), + ExpressionTestCase( + "decimal128_nan_start", + doc={"start": DECIMAL128_NAN, "end": 5}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_START_NOT_INTEGRAL_ERROR, + msg="$range should reject Decimal128 NaN start", + ), + ExpressionTestCase( + "decimal128_inf_start", + doc={"start": DECIMAL128_INFINITY, "end": 5}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_START_NOT_INTEGRAL_ERROR, + msg="$range should reject Decimal128 Infinity start", + ), + ExpressionTestCase( + "decimal128_neg_inf_end", + doc={"start": 0, "end": DECIMAL128_NEGATIVE_INFINITY}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_END_NOT_INT32_ERROR, + msg="$range should reject Decimal128 -Infinity end", + ), +] + +# Property [Step Zero]: $range rejects zero step value. +STEP_ZERO_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "step_zero_int", + doc={"start": 0, "end": 5, "step": 0}, + expression={"$range": ["$start", "$end", "$step"]}, + error_code=RANGE_STEP_ZERO_ERROR, + msg="$range should reject step 0", + ), + ExpressionTestCase( + "step_zero_int64", + doc={"start": 0, "end": 5, "step": INT64_ZERO}, + expression={"$range": ["$start", "$end", "$step"]}, + error_code=RANGE_STEP_ZERO_ERROR, + msg="$range should reject Int64 step 0", + ), + ExpressionTestCase( + "step_zero_double", + doc={"start": 0, "end": 5, "step": DOUBLE_ZERO}, + expression={"$range": ["$start", "$end", "$step"]}, + error_code=RANGE_STEP_ZERO_ERROR, + msg="$range should reject double step 0.0", + ), + ExpressionTestCase( + "step_zero_decimal128", + doc={"start": 0, "end": 5, "step": DECIMAL128_ZERO}, + expression={"$range": ["$start", "$end", "$step"]}, + error_code=RANGE_STEP_ZERO_ERROR, + msg="$range should reject Decimal128 step 0", + ), + ExpressionTestCase( + "step_neg_zero_double", + doc={"start": 0, "end": 5, "step": DOUBLE_NEGATIVE_ZERO}, + expression={"$range": ["$start", "$end", "$step"]}, + error_code=RANGE_STEP_ZERO_ERROR, + msg="$range should reject negative zero double step", + ), + ExpressionTestCase( + "step_neg_zero_decimal128", + doc={"start": 0, "end": 5, "step": DECIMAL128_NEGATIVE_ZERO}, + expression={"$range": ["$start", "$end", "$step"]}, + error_code=RANGE_STEP_ZERO_ERROR, + msg="$range should reject negative zero Decimal128 step", + ), +] + +# Property [Out of INT32 Range]: $range rejects values outside int32 range. +OUT_OF_INT32_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "start_int64_max", + doc={"start": INT64_MAX, "end": 5}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_START_NOT_INTEGRAL_ERROR, + msg="$range should reject INT64_MAX start", + ), + ExpressionTestCase( + "start_int64_min", + doc={"start": INT64_MIN, "end": 5}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_START_NOT_INTEGRAL_ERROR, + msg="$range should reject INT64_MIN start", + ), + ExpressionTestCase( + "end_int64_max", + doc={"start": 0, "end": INT64_MAX}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_END_NOT_INT32_ERROR, + msg="$range should reject INT64_MAX end", + ), + ExpressionTestCase( + "end_int64_min", + doc={"start": 0, "end": INT64_MIN}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_END_NOT_INT32_ERROR, + msg="$range should reject INT64_MIN end", + ), + ExpressionTestCase( + "step_int64_max", + doc={"start": 0, "end": 5, "step": INT64_MAX}, + expression={"$range": ["$start", "$end", "$step"]}, + error_code=RANGE_STEP_NOT_INT32_ERROR, + msg="$range should reject INT64_MAX step", + ), + ExpressionTestCase( + "start_int32_overflow", + doc={"start": INT32_OVERFLOW, "end": 5}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_START_NOT_INTEGRAL_ERROR, + msg="$range should reject INT32_OVERFLOW start", + ), + ExpressionTestCase( + "start_int32_underflow", + doc={"start": INT32_UNDERFLOW, "end": 5}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_START_NOT_INTEGRAL_ERROR, + msg="$range should reject INT32_UNDERFLOW start", + ), + ExpressionTestCase( + "end_int32_overflow", + doc={"start": 0, "end": INT32_OVERFLOW}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_END_NOT_INT32_ERROR, + msg="$range should reject INT32_OVERFLOW end", + ), + ExpressionTestCase( + "end_int32_underflow", + doc={"start": 0, "end": INT32_UNDERFLOW}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_END_NOT_INT32_ERROR, + msg="$range should reject INT32_UNDERFLOW end", + ), + ExpressionTestCase( + "step_int32_overflow", + doc={"start": 0, "end": 5, "step": INT32_OVERFLOW}, + expression={"$range": ["$start", "$end", "$step"]}, + error_code=RANGE_STEP_NOT_INT32_ERROR, + msg="$range should reject INT32_OVERFLOW step", + ), + ExpressionTestCase( + "step_int32_underflow", + doc={"start": 0, "end": 5, "step": INT32_UNDERFLOW}, + expression={"$range": ["$start", "$end", "$step"]}, + error_code=RANGE_STEP_NOT_INT32_ERROR, + msg="$range should reject INT32_UNDERFLOW step", + ), +] + +# Property [Arity]: $range rejects wrong number of arguments. +ARITY_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "zero_args", + expression={"$range": []}, + error_code=EXPRESSION_ARITY_ERROR, + msg="$range should error with zero arguments", + ), + ExpressionTestCase( + "one_arg", + expression={"$range": [1]}, + error_code=EXPRESSION_ARITY_ERROR, + msg="$range should error with one argument", + ), + ExpressionTestCase( + "four_args", + expression={"$range": [1, 2, 3, 4]}, + error_code=EXPRESSION_ARITY_ERROR, + msg="$range should error with four arguments", + ), +] + +ALL_TESTS = ( + NON_INTEGRAL_START_TESTS + + NON_INTEGRAL_END_TESTS + + NON_INTEGRAL_STEP_TESTS + + SPECIAL_NUMERIC_TESTS + + STEP_ZERO_TESTS + + OUT_OF_INT32_TESTS + + ARITY_ERROR_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_range_value_error(collection, test): + """Test $range error with invalid numeric values.""" + if test.doc is None: + result = execute_expression(collection, test.expression) + else: + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/range/test_smoke_expression_range.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/range/test_smoke_range.py similarity index 89% rename from documentdb_tests/compatibility/tests/core/operator/expressions/array/range/test_smoke_expression_range.py rename to documentdb_tests/compatibility/tests/core/operator/expressions/array/range/test_smoke_range.py index a425f73e3..22bf631f0 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/array/range/test_smoke_expression_range.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/range/test_smoke_range.py @@ -26,4 +26,4 @@ def test_smoke_expression_range(collection): ) expected = [{"_id": 1, "sequence": [0, 1, 2, 3, 4]}, {"_id": 2, "sequence": [10, 11, 12]}] - assertSuccess(result, expected, msg="Should support $range expression") + assertSuccess(result, expected, "Should support $range expression", ignore_doc_order=True) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/zip/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/zip/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/zip/test_smoke_expression_zip.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/zip/test_smoke_zip.py similarity index 89% rename from documentdb_tests/compatibility/tests/core/operator/expressions/array/zip/test_smoke_expression_zip.py rename to documentdb_tests/compatibility/tests/core/operator/expressions/array/zip/test_smoke_zip.py index 50f19ac0b..8e8b9d65a 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/array/zip/test_smoke_expression_zip.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/zip/test_smoke_zip.py @@ -28,4 +28,4 @@ def test_smoke_expression_zip(collection): ) expected = [{"_id": 1, "zipped": [[1, 10], [2, 20]]}, {"_id": 2, "zipped": [[3, 30], [4, 40]]}] - assertSuccess(result, expected, msg="Should support $zip expression") + assertSuccess(result, expected, "Should support $zip expression", ignore_doc_order=True) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/zip/test_zip_argument_structure_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/zip/test_zip_argument_structure_errors.py new file mode 100644 index 000000000..335eca07b --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/zip/test_zip_argument_structure_errors.py @@ -0,0 +1,134 @@ +""" +Argument handling tests for $zip expression. + +Tests object structure validation: missing fields, extra fields, +non-object argument, empty inputs, inputs not being an array, +useLongestLength truthy/falsy coercion, and defaults type variety. +""" + +import pytest +from bson import MaxKey, MinKey + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( # noqa: E501 + assert_expression_result, + execute_expression, +) +from documentdb_tests.framework.error_codes import ( + ZIP_INPUT_ELEMENT_NOT_ARRAY_ERROR, + ZIP_INPUTS_NOT_ARRAY_ERROR, + ZIP_MISSING_INPUTS_ERROR, + ZIP_UNKNOWN_FIELD_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Argument Structure]: $zip rejects malformed arguments. +STRUCTURE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "empty_object", + expression={"$zip": {}}, + error_code=ZIP_MISSING_INPUTS_ERROR, + msg="$zip empty object should error", + ), + ExpressionTestCase( + "missing_inputs", + expression={"$zip": {"useLongestLength": True}}, + error_code=ZIP_MISSING_INPUTS_ERROR, + msg="$zip missing inputs should error", + ), + ExpressionTestCase( + "unknown_field", + expression={"$zip": {"inputs": [[1], [2]], "extra": 1}}, + error_code=ZIP_UNKNOWN_FIELD_ERROR, + msg="$zip unknown field should error", + ), + ExpressionTestCase( + "inputs_not_array", + expression={"$zip": {"inputs": "bad"}}, + error_code=ZIP_INPUT_ELEMENT_NOT_ARRAY_ERROR, + msg="$zip non-array inputs should error", + ), + ExpressionTestCase( + "inputs_not_array_int", + expression={"$zip": {"inputs": 1}}, + error_code=ZIP_INPUT_ELEMENT_NOT_ARRAY_ERROR, + msg="$zip int inputs should error", + ), + ExpressionTestCase( + "inputs_empty_array", + expression={"$zip": {"inputs": []}}, + error_code=ZIP_MISSING_INPUTS_ERROR, + msg="$zip empty inputs array should error", + ), + ExpressionTestCase( + "inputs_as_object", + expression={"$zip": {"inputs": {"a": "b"}}}, + error_code=ZIP_INPUT_ELEMENT_NOT_ARRAY_ERROR, + msg="$zip inputs as object should error", + ), +] + +# Property [Object Argument]: $zip rejects non-object arguments. +NON_OBJECT_ARG_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int_arg", + expression={"$zip": 1}, + error_code=ZIP_INPUTS_NOT_ARRAY_ERROR, + msg="$zip int argument should error", + ), + ExpressionTestCase( + "string_arg", + expression={"$zip": "abc"}, + error_code=ZIP_INPUTS_NOT_ARRAY_ERROR, + msg="$zip string argument should error", + ), + ExpressionTestCase( + "array_arg", + expression={"$zip": [1, 2]}, + error_code=ZIP_INPUTS_NOT_ARRAY_ERROR, + msg="$zip array argument should error", + ), + ExpressionTestCase( + "null_arg", + expression={"$zip": None}, + error_code=ZIP_INPUTS_NOT_ARRAY_ERROR, + msg="$zip null argument should error", + ), + ExpressionTestCase( + "bool_arg", + expression={"$zip": True}, + error_code=ZIP_INPUTS_NOT_ARRAY_ERROR, + msg="$zip bool argument should error", + ), + ExpressionTestCase( + "double_arg", + expression={"$zip": 1.5}, + error_code=ZIP_INPUTS_NOT_ARRAY_ERROR, + msg="$zip double argument should error", + ), + ExpressionTestCase( + "minkey_arg", + expression={"$zip": MinKey()}, + error_code=ZIP_INPUTS_NOT_ARRAY_ERROR, + msg="$zip minKey argument should error", + ), + ExpressionTestCase( + "maxkey_arg", + expression={"$zip": MaxKey()}, + error_code=ZIP_INPUTS_NOT_ARRAY_ERROR, + msg="$zip maxKey argument should error", + ), +] + +ALL_STRUCTURE_TESTS = STRUCTURE_ERROR_TESTS + NON_OBJECT_ARG_TESTS + + +@pytest.mark.parametrize("test", pytest_params(ALL_STRUCTURE_TESTS)) +def test_zip_argument_handling(collection, test): + """Test $zip argument structure validation.""" + result = execute_expression(collection, test.expression) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/zip/test_zip_bson_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/zip/test_zip_bson_types.py new file mode 100644 index 000000000..ff502fff7 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/zip/test_zip_bson_types.py @@ -0,0 +1,395 @@ +""" +BSON type element preservation tests for $zip expression. + +Tests that various BSON types are preserved when zipping arrays. +""" + +from datetime import datetime, timezone +from uuid import UUID + +import pytest +from bson import Binary, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( # noqa: E501 + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_ONE_AND_HALF, + DECIMAL128_TWO_AND_HALF, + DECIMAL128_ZERO, + DOUBLE_NEGATIVE_ZERO, + FLOAT_INFINITY, + FLOAT_NEGATIVE_INFINITY, + INT32_MAX, + INT32_MIN, + INT64_MAX, + INT64_MIN, + INT64_ZERO, +) + +# Property [Type Preservation]: $zip preserves each element's BSON type. +BSON_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int64_values", + doc={"arr0": [Int64(1)], "arr1": [Int64(2)]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + expected=[[Int64(1), Int64(2)]], + msg="$zip should preserve Int64 values", + ), + ExpressionTestCase( + "decimal128_values", + doc={"arr0": [DECIMAL128_ONE_AND_HALF], "arr1": [DECIMAL128_TWO_AND_HALF]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + expected=[[DECIMAL128_ONE_AND_HALF, DECIMAL128_TWO_AND_HALF]], + msg="$zip should preserve Decimal128 values", + ), + ExpressionTestCase( + "datetime_values", + doc={ + "arr0": [datetime(2024, 1, 1, tzinfo=timezone.utc)], + "arr1": [datetime(2024, 6, 1, tzinfo=timezone.utc)], + }, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + expected=[ + [datetime(2024, 1, 1, tzinfo=timezone.utc), datetime(2024, 6, 1, tzinfo=timezone.utc)] + ], + msg="$zip should preserve datetime values", + ), + ExpressionTestCase( + "objectid_values", + doc={ + "arr0": [ObjectId("000000000000000000000001")], + "arr1": [ObjectId("000000000000000000000002")], + }, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + expected=[[ObjectId("000000000000000000000001"), ObjectId("000000000000000000000002")]], + msg="$zip should preserve ObjectId values", + ), + ExpressionTestCase( + "binary_values", + doc={"arr0": [Binary(b"\x01", 0)], "arr1": [Binary(b"\x02", 0)]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + expected=[[b"\x01", b"\x02"]], + msg="$zip should preserve Binary values", + ), + ExpressionTestCase( + "regex_values", + doc={"arr0": [Regex("^a", "i")], "arr1": [Regex("^b", "i")]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + expected=[[Regex("^a", "i"), Regex("^b", "i")]], + msg="$zip should preserve Regex values", + ), + ExpressionTestCase( + "timestamp_values", + doc={"arr0": [Timestamp(1, 0)], "arr1": [Timestamp(2, 0)]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + expected=[[Timestamp(1, 0), Timestamp(2, 0)]], + msg="$zip should preserve Timestamp values", + ), + ExpressionTestCase( + "minkey_maxkey", + doc={"arr0": [MinKey()], "arr1": [MaxKey()]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + expected=[[MinKey(), MaxKey()]], + msg="$zip should preserve MinKey/MaxKey values", + ), + ExpressionTestCase( + "uuid_values", + doc={ + "arr0": [Binary.from_uuid(UUID("01234567-89ab-cdef-fedc-ba9876543210"))], + "arr1": [Binary.from_uuid(UUID("fedcba98-7654-3210-0123-456789abcdef"))], + }, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + expected=[ + [ + Binary.from_uuid(UUID("01234567-89ab-cdef-fedc-ba9876543210")), + Binary.from_uuid(UUID("fedcba98-7654-3210-0123-456789abcdef")), + ] + ], + msg="$zip should preserve UUID binary values", + ), +] + +# Property [Mixed Types]: $zip processes arrays with mixed BSON types. +MIXED_BSON_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "mixed_bson_types", + doc={"arr0": [1, "two", Int64(3)], "arr1": [Decimal128("4"), True, MinKey()]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + expected=[[1, Decimal128("4")], ["two", True], [Int64(3), MinKey()]], + msg="$zip should zip mixed BSON types preserving each", + ), +] + +# Property [Special Numerics]: $zip preserves NaN, Infinity, and boundary values. +SPECIAL_NUMERIC_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "infinity_values", + doc={"arr0": [FLOAT_INFINITY], "arr1": [FLOAT_NEGATIVE_INFINITY]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + expected=[[FLOAT_INFINITY, FLOAT_NEGATIVE_INFINITY]], + msg="$zip should preserve infinity values", + ), + ExpressionTestCase( + "decimal128_infinity", + doc={"arr0": [DECIMAL128_INFINITY], "arr1": [DECIMAL128_NEGATIVE_INFINITY]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + expected=[[DECIMAL128_INFINITY, DECIMAL128_NEGATIVE_INFINITY]], + msg="$zip should preserve Decimal128 infinity values", + ), + ExpressionTestCase( + "boundary_values", + doc={"arr0": [INT32_MIN, INT32_MAX], "arr1": [INT64_MIN, INT64_MAX]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + expected=[[INT32_MIN, INT64_MIN], [INT32_MAX, INT64_MAX]], + msg="$zip should preserve numeric boundary values", + ), + ExpressionTestCase( + "negative_zero", + doc={"arr0": [DOUBLE_NEGATIVE_ZERO], "arr1": [0]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + expected=[[DOUBLE_NEGATIVE_ZERO, 0]], + msg="$zip should preserve negative zero", + ), + ExpressionTestCase( + "decimal128_nan", + doc={"arr0": [DECIMAL128_NAN], "arr1": [Decimal128("1")]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + expected=[[DECIMAL128_NAN, Decimal128("1")]], + msg="$zip should preserve Decimal128 NaN", + ), +] + +# Property [BSON Defaults]: $zip uses BSON-typed default values correctly. +BSON_DEFAULTS_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "default_int64", + doc={"arr0": [1, 2, 3], "arr1": [Int64(10)]}, + expression={ + "$zip": { + "inputs": ["$arr0", "$arr1"], + "useLongestLength": True, + "defaults": [0, INT64_ZERO], + } + }, + expected=[[1, Int64(10)], [2, INT64_ZERO], [3, INT64_ZERO]], + msg="$zip should use Int64 default value", + ), + ExpressionTestCase( + "default_decimal128", + doc={"arr0": [1, 2, 3], "arr1": [DECIMAL128_ONE_AND_HALF]}, + expression={ + "$zip": { + "inputs": ["$arr0", "$arr1"], + "useLongestLength": True, + "defaults": [0, DECIMAL128_ZERO], + } + }, + expected=[[1, DECIMAL128_ONE_AND_HALF], [2, DECIMAL128_ZERO], [3, DECIMAL128_ZERO]], + msg="$zip should use Decimal128 default value", + ), + ExpressionTestCase( + "default_datetime", + doc={"arr0": [1, 2], "arr1": [datetime(2024, 1, 1, tzinfo=timezone.utc)]}, + expression={ + "$zip": { + "inputs": ["$arr0", "$arr1"], + "useLongestLength": True, + "defaults": [0, datetime(1970, 1, 1, tzinfo=timezone.utc)], + } + }, + expected=[ + [1, datetime(2024, 1, 1, tzinfo=timezone.utc)], + [2, datetime(1970, 1, 1, tzinfo=timezone.utc)], + ], + msg="$zip should use datetime default value", + ), + ExpressionTestCase( + "default_objectid", + doc={"arr0": [1, 2], "arr1": [ObjectId("000000000000000000000001")]}, + expression={ + "$zip": { + "inputs": ["$arr0", "$arr1"], + "useLongestLength": True, + "defaults": [0, ObjectId("000000000000000000000000")], + } + }, + expected=[ + [1, ObjectId("000000000000000000000001")], + [2, ObjectId("000000000000000000000000")], + ], + msg="$zip should use ObjectId default value", + ), + ExpressionTestCase( + "default_timestamp", + doc={"arr0": [1, 2], "arr1": [Timestamp(1, 0)]}, + expression={ + "$zip": { + "inputs": ["$arr0", "$arr1"], + "useLongestLength": True, + "defaults": [0, Timestamp(0, 0)], + } + }, + expected=[[1, Timestamp(1, 0)], [2, Timestamp(0, 0)]], + msg="$zip should use Timestamp default value", + ), + ExpressionTestCase( + "default_regex", + doc={"arr0": [1, 2], "arr1": [Regex("^a", "i")]}, + expression={ + "$zip": { + "inputs": ["$arr0", "$arr1"], + "useLongestLength": True, + "defaults": [0, Regex(".*", "")], + } + }, + expected=[[1, Regex("^a", "i")], [2, Regex(".*", "")]], + msg="$zip should use Regex default value", + ), + ExpressionTestCase( + "default_minkey_maxkey", + doc={"arr0": [1, 2], "arr1": [MinKey()]}, + expression={ + "$zip": { + "inputs": ["$arr0", "$arr1"], + "useLongestLength": True, + "defaults": [0, MaxKey()], + } + }, + expected=[[1, MinKey()], [2, MaxKey()]], + msg="$zip should use MaxKey default value", + ), + ExpressionTestCase( + "default_binary", + doc={"arr0": [1, 2], "arr1": [Binary(b"\x01", 0)]}, + expression={ + "$zip": { + "inputs": ["$arr0", "$arr1"], + "useLongestLength": True, + "defaults": [0, Binary(b"\x00", 0)], + } + }, + expected=[[1, b"\x01"], [2, b"\x00"]], + msg="$zip should use Binary default value", + ), +] + +# Property [Special Numeric Defaults]: $zip uses special numeric values as defaults. +SPECIAL_NUMERIC_DEFAULTS_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "default_infinity", + doc={"arr0": [1, 2], "arr1": [FLOAT_INFINITY]}, + expression={ + "$zip": { + "inputs": ["$arr0", "$arr1"], + "useLongestLength": True, + "defaults": [0, FLOAT_INFINITY], + } + }, + expected=[[1, FLOAT_INFINITY], [2, FLOAT_INFINITY]], + msg="$zip should use infinity as default", + ), + ExpressionTestCase( + "default_negative_infinity", + doc={"arr0": [1, 2], "arr1": [FLOAT_NEGATIVE_INFINITY]}, + expression={ + "$zip": { + "inputs": ["$arr0", "$arr1"], + "useLongestLength": True, + "defaults": [0, FLOAT_NEGATIVE_INFINITY], + } + }, + expected=[[1, FLOAT_NEGATIVE_INFINITY], [2, FLOAT_NEGATIVE_INFINITY]], + msg="$zip should use negative infinity as default", + ), + ExpressionTestCase( + "default_negative_zero", + doc={"arr0": [1, 2], "arr1": [DOUBLE_NEGATIVE_ZERO]}, + expression={ + "$zip": { + "inputs": ["$arr0", "$arr1"], + "useLongestLength": True, + "defaults": [0, DOUBLE_NEGATIVE_ZERO], + } + }, + expected=[[1, DOUBLE_NEGATIVE_ZERO], [2, DOUBLE_NEGATIVE_ZERO]], + msg="$zip should use negative zero as default", + ), + ExpressionTestCase( + "default_int32_boundaries", + doc={"arr0": [1, 2], "arr1": [INT32_MIN]}, + expression={ + "$zip": { + "inputs": ["$arr0", "$arr1"], + "useLongestLength": True, + "defaults": [0, INT32_MAX], + } + }, + expected=[[1, INT32_MIN], [2, INT32_MAX]], + msg="$zip should use INT32_MAX as default", + ), + ExpressionTestCase( + "default_int64_boundaries", + doc={"arr0": [1, 2], "arr1": [INT64_MIN]}, + expression={ + "$zip": { + "inputs": ["$arr0", "$arr1"], + "useLongestLength": True, + "defaults": [0, INT64_MAX], + } + }, + expected=[[1, INT64_MIN], [2, INT64_MAX]], + msg="$zip should use INT64_MAX as default", + ), + ExpressionTestCase( + "default_decimal128_infinity", + doc={"arr0": [1, 2], "arr1": [DECIMAL128_INFINITY]}, + expression={ + "$zip": { + "inputs": ["$arr0", "$arr1"], + "useLongestLength": True, + "defaults": [0, DECIMAL128_NEGATIVE_INFINITY], + } + }, + expected=[[1, DECIMAL128_INFINITY], [2, DECIMAL128_NEGATIVE_INFINITY]], + msg="$zip should use Decimal128 negative infinity as default", + ), + ExpressionTestCase( + "default_decimal128_nan", + doc={"arr0": [1, 2], "arr1": [DECIMAL128_NAN]}, + expression={ + "$zip": { + "inputs": ["$arr0", "$arr1"], + "useLongestLength": True, + "defaults": [0, DECIMAL128_NAN], + } + }, + expected=[[1, DECIMAL128_NAN], [2, DECIMAL128_NAN]], + msg="$zip should use Decimal128 NaN as default", + ), +] + +ALL_BSON_TESTS = ( + BSON_TYPE_TESTS + + MIXED_BSON_TESTS + + SPECIAL_NUMERIC_TESTS + + BSON_DEFAULTS_TESTS + + SPECIAL_NUMERIC_DEFAULTS_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_BSON_TESTS)) +def test_zip_bson_type_preservation(collection, test): + """Test $zip preserves BSON types in zipped output arrays.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/zip/test_zip_core_behavior.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/zip/test_zip_core_behavior.py new file mode 100644 index 000000000..33468aaa7 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/zip/test_zip_core_behavior.py @@ -0,0 +1,230 @@ +""" +Core behavior tests for $zip expression. + +Tests zipping arrays of various element types, equal/unequal lengths, +useLongestLength, and defaults. +""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( # noqa: E501 + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Basic Transform]: $zip transposes arrays element-wise. +BASIC_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "two_int_arrays", + doc={"arr0": [1, 2, 3], "arr1": [10, 20, 30]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + expected=[[1, 10], [2, 20], [3, 30]], + msg="$zip should zip two int arrays", + ), + ExpressionTestCase( + "two_string_arrays", + doc={"arr0": ["a", "b"], "arr1": ["c", "d"]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + expected=[["a", "c"], ["b", "d"]], + msg="$zip should zip two string arrays", + ), + ExpressionTestCase( + "three_arrays", + doc={"arr0": [1, 2], "arr1": [10, 20], "arr2": [100, 200]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1", "$arr2"]}}, + expected=[[1, 10, 100], [2, 20, 200]], + msg="$zip should zip three arrays", + ), + ExpressionTestCase( + "mixed_type_elements", + doc={"arr0": [1, "two"], "arr1": [True, None]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + expected=[[1, True], ["two", None]], + msg="$zip should zip arrays with mixed types", + ), + ExpressionTestCase( + "numeric_cross_types", + doc={"arr0": [1, Int64(2)], "arr1": [3.0, Decimal128("4")]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + expected=[[1, 3.0], [Int64(2), Decimal128("4")]], + msg="$zip should zip mixed numeric type arrays", + ), +] + +# Property [Unequal Length]: $zip truncates to the shortest array. +UNEQUAL_LENGTH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "first_shorter", + doc={"arr0": [1, 2], "arr1": [10, 20, 30]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + expected=[[1, 10], [2, 20]], + msg="$zip should truncate to shorter first array", + ), + ExpressionTestCase( + "second_shorter", + doc={"arr0": [1, 2, 3], "arr1": [10, 20]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + expected=[[1, 10], [2, 20]], + msg="$zip should truncate to shorter second array", + ), + ExpressionTestCase( + "one_empty", + doc={"arr0": [], "arr1": [1, 2, 3]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + expected=[], + msg="$zip empty array should produce empty result", + ), +] + +# Property [Longest Length]: $zip pads to longest array when useLongestLength is true. +USE_LONGEST_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "longest_pads_null", + doc={"arr0": [1, 2, 3], "arr1": [10, 20]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"], "useLongestLength": True}}, + expected=[[1, 10], [2, 20], [3, None]], + msg="$zip should pad shorter array with null", + ), + ExpressionTestCase( + "longest_both_short", + doc={"arr0": [1], "arr1": [10, 20], "arr2": [100, 200, 300]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1", "$arr2"], "useLongestLength": True}}, + expected=[[1, 10, 100], [None, 20, 200], [None, None, 300]], + msg="$zip should pad multiple shorter arrays with null", + ), + ExpressionTestCase( + "longest_equal_length", + doc={"arr0": [1, 2], "arr1": [10, 20]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"], "useLongestLength": True}}, + expected=[[1, 10], [2, 20]], + msg="$zip equal length with useLongestLength should behave same", + ), + ExpressionTestCase( + "longest_false_truncates", + doc={"arr0": [1, 2, 3], "arr1": [10, 20]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"], "useLongestLength": False}}, + expected=[[1, 10], [2, 20]], + msg="$zip useLongestLength false should truncate", + ), +] + +# Property [Defaults]: $zip pads shorter arrays with default values. +DEFAULTS_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "defaults_fill_shorter", + doc={"arr0": [1, 2, 3], "arr1": [10, 20]}, + expression={ + "$zip": {"inputs": ["$arr0", "$arr1"], "useLongestLength": True, "defaults": [0, 0]} + }, + expected=[[1, 10], [2, 20], [3, 0]], + msg="$zip should fill shorter array with default value", + ), + ExpressionTestCase( + "defaults_multiple_arrays", + doc={"arr0": [1], "arr1": [10, 20], "arr2": [100, 200, 300]}, + expression={ + "$zip": { + "inputs": ["$arr0", "$arr1", "$arr2"], + "useLongestLength": True, + "defaults": [-1, -2, -3], + } + }, + expected=[[1, 10, 100], [-1, 20, 200], [-1, -2, 300]], + msg="$zip should fill multiple shorter arrays with respective defaults", + ), + ExpressionTestCase( + "defaults_null_value", + doc={"arr0": [1, 2, 3], "arr1": [10]}, + expression={ + "$zip": { + "inputs": ["$arr0", "$arr1"], + "useLongestLength": True, + "defaults": [None, None], + } + }, + expected=[[1, 10], [2, None], [3, None]], + msg="$zip null defaults should work same as no defaults", + ), + ExpressionTestCase( + "defaults_string", + doc={"arr0": [1, 2, 3], "arr1": ["a"]}, + expression={ + "$zip": { + "inputs": ["$arr0", "$arr1"], + "useLongestLength": True, + "defaults": [0, "missing"], + } + }, + expected=[[1, "a"], [2, "missing"], [3, "missing"]], + msg="$zip should use string default value", + ), + ExpressionTestCase( + "defaults_not_used_equal_length", + doc={"arr0": [1, 2], "arr1": [10, 20]}, + expression={ + "$zip": {"inputs": ["$arr0", "$arr1"], "useLongestLength": True, "defaults": [0, 0]} + }, + expected=[[1, 10], [2, 20]], + msg="$zip defaults not used when arrays are equal length", + ), + ExpressionTestCase( + "defaults_falsy_empty_string", + doc={"arr0": [1, 2], "arr1": ["a"]}, + expression={ + "$zip": {"inputs": ["$arr0", "$arr1"], "useLongestLength": True, "defaults": [0, ""]} + }, + expected=[[1, "a"], [2, ""]], + msg="$zip falsy defaults (empty string) used correctly", + ), + ExpressionTestCase( + "defaults_false", + doc={"arr0": [1, 2], "arr1": ["a"]}, + expression={ + "$zip": {"inputs": ["$arr0", "$arr1"], "useLongestLength": True, "defaults": [0, False]} + }, + expected=[[1, "a"], [2, False]], + msg="$zip false default used correctly", + ), + ExpressionTestCase( + "defaults_complex_types", + doc={"arr0": [1, 2], "arr1": ["a"]}, + expression={ + "$zip": { + "inputs": ["$arr0", "$arr1"], + "useLongestLength": True, + "defaults": [{"x": 1}, [1, 2]], + } + }, + expected=[[1, "a"], [2, [1, 2]]], + msg="$zip complex type defaults used correctly", + ), + ExpressionTestCase( + "defaults_nested_array", + doc={"arr0": [1, 2], "arr1": ["a"]}, + expression={ + "$zip": { + "inputs": ["$arr0", "$arr1"], + "useLongestLength": True, + "defaults": [[1, 2], "fallback"], + } + }, + expected=[[1, "a"], [2, "fallback"]], + msg="$zip nested array default used as-is", + ), +] + +ALL_TESTS = BASIC_TESTS + UNEQUAL_LENGTH_TESTS + USE_LONGEST_TESTS + DEFAULTS_TESTS + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_zip_core_behavior(collection, test): + """Test $zip core behavior: basic zipping, unequal lengths, useLongestLength, defaults.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/zip/test_zip_degenerate_cases.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/zip/test_zip_degenerate_cases.py new file mode 100644 index 000000000..09e8faf6a --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/zip/test_zip_degenerate_cases.py @@ -0,0 +1,239 @@ +""" +Degenerate and edge case tests for $zip expression. + +Tests empty arrays, single arrays, nested arrays, null propagation, +objects as elements, large arrays, many inputs, and multi-length arrays. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( # noqa: E501 + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Empty/Single]: $zip handles empty arrays and single-element arrays. +DEGENERATE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "both_empty", + doc={"arr0": [], "arr1": []}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + expected=[], + msg="$zip should return empty for two empty arrays", + ), + ExpressionTestCase( + "three_empty", + doc={"arr0": [], "arr1": [], "arr2": []}, + expression={"$zip": {"inputs": ["$arr0", "$arr1", "$arr2"]}}, + expected=[], + msg="$zip three empty arrays return []", + ), + ExpressionTestCase( + "single_empty", + doc={"arr0": []}, + expression={"$zip": {"inputs": ["$arr0"]}}, + expected=[], + msg="$zip single empty array returns []", + ), + ExpressionTestCase( + "single_element_each", + doc={"arr0": [1], "arr1": [10]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + expected=[[1, 10]], + msg="$zip should zip single-element arrays", + ), + ExpressionTestCase( + "single_input_array", + doc={"arr0": [1, 2, 3]}, + expression={"$zip": {"inputs": ["$arr0"]}}, + expected=[[1], [2], [3]], + msg="$zip single input should wrap each element", + ), + ExpressionTestCase( + "all_single_element_three", + doc={"arr0": [1], "arr1": ["a"], "arr2": [True]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1", "$arr2"]}}, + expected=[[1, "a", True]], + msg="$zip all single-element arrays produce one row", + ), + ExpressionTestCase( + "empty_with_longest_true", + doc={"arr0": [], "arr1": [1, 2, 3]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"], "useLongestLength": True}}, + expected=[[None, 1], [None, 2], [None, 3]], + msg="$zip empty array with longest length pads with null", + ), + ExpressionTestCase( + "two_empty_longest_true", + doc={"arr0": [], "arr1": []}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"], "useLongestLength": True}}, + expected=[], + msg="$zip two empty arrays with longest length return []", + ), +] + +# Property [Nested Arrays]: $zip preserves nested array and object elements. +NESTED_ARRAY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_arrays", + doc={"arr0": [[1, 2], [3, 4]], "arr1": ["a", "b"]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + expected=[[[1, 2], "a"], [[3, 4], "b"]], + msg="$zip should zip nested arrays as elements", + ), + ExpressionTestCase( + "objects_as_elements", + doc={"arr0": [{"x": 1}, {"x": 2}], "arr1": [{"y": 10}, {"y": 20}]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + expected=[[{"x": 1}, {"y": 10}], [{"x": 2}, {"y": 20}]], + msg="$zip objects preserved as elements", + ), + ExpressionTestCase( + "mixed_types_six", + doc={"arr0": [1, "a", None, True, {"k": 1}, [9]], "arr1": [10, 20, 30, 40, 50, 60]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + expected=[[1, 10], ["a", 20], [None, 30], [True, 40], [{"k": 1}, 50], [[9], 60]], + msg="$zip mixed types preserved in transposition", + ), +] + +# Property [Null Propagation]: $zip returns null when any input array is null. +NULL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_first_input", + doc={"arr0": None, "arr1": [1, 2]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + expected=None, + msg="$zip should return null when first input is null", + ), + ExpressionTestCase( + "null_second_input", + doc={"arr0": [1, 2], "arr1": None}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + expected=None, + msg="$zip should return null when second input is null", + ), + ExpressionTestCase( + "all_null_inputs", + doc={"arr0": None, "arr1": None}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + expected=None, + msg="$zip should return null when all inputs are null", + ), + ExpressionTestCase( + "null_elements_in_arrays", + doc={"arr0": [1, None], "arr1": [None, 2]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + expected=[[1, None], [None, 2]], + msg="$zip should preserve null elements within arrays", + ), +] + +# Property [Object Elements]: $zip preserves object elements within arrays. +OBJECT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "arrays_of_objects", + doc={"arr0": [{"a": 1}], "arr1": [{"b": 2}]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + expected=[[{"a": 1}, {"b": 2}]], + msg="$zip should zip arrays of objects", + ), +] + +# Property [Large Arrays]: $zip handles large arrays. +LARGE_ARRAY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "large_arrays", + doc={"arr0": list(range(1000)), "arr1": list(range(1000, 2000))}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + expected=[[i, i + 1000] for i in range(1000)], + msg="$zip should zip large arrays", + ), +] + +# Property [Many Inputs]: $zip handles many input arrays. +MANY_INPUTS_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "ten_inputs", + doc={f"arr{i}": [i] for i in range(10)}, + expression={"$zip": {"inputs": [f"$arr{i}" for i in range(10)]}}, + expected=[list(range(10))], + msg="$zip ten inputs transpose correctly", + ), + ExpressionTestCase( + "fifty_inputs", + doc={f"arr{i}": [i] for i in range(50)}, + expression={"$zip": {"inputs": [f"$arr{i}" for i in range(50)]}}, + expected=[list(range(50))], + msg="$zip 50 single-element inputs produce one 50-element row", + ), +] + +# Property [Multi-Length]: $zip handles multiple arrays of varying lengths. +MULTI_LENGTH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "three_arrays_shortest", + doc={"arr0": [1], "arr1": [10, 20, 30], "arr2": [100, 200, 300, 400, 500]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1", "$arr2"]}}, + expected=[[1, 10, 100]], + msg="$zip three arrays shortest = 1", + ), + ExpressionTestCase( + "three_arrays_longest_no_defaults", + doc={"arr0": [1], "arr1": [10, 20, 30], "arr2": [100, 200, 300, 400, 500]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1", "$arr2"], "useLongestLength": True}}, + expected=[ + [1, 10, 100], + [None, 20, 200], + [None, 30, 300], + [None, None, 400], + [None, None, 500], + ], + msg="$zip three arrays longest pads with null", + ), + ExpressionTestCase( + "three_arrays_longest_with_defaults", + doc={"arr0": [1], "arr1": [10, 20, 30], "arr2": [100, 200, 300, 400, 500]}, + expression={ + "$zip": { + "inputs": ["$arr0", "$arr1", "$arr2"], + "useLongestLength": True, + "defaults": [0, "x", False], + } + }, + expected=[[1, 10, 100], [0, 20, 200], [0, 30, 300], [0, "x", 400], [0, "x", 500]], + msg="$zip three arrays longest with defaults", + ), + ExpressionTestCase( + "four_arrays_two_empty", + doc={"arr0": [], "arr1": [], "arr2": [1, 2], "arr3": [3, 4, 5]}, + expression={ + "$zip": {"inputs": ["$arr0", "$arr1", "$arr2", "$arr3"], "useLongestLength": True} + }, + expected=[[None, None, 1, 3], [None, None, 2, 4], [None, None, None, 5]], + msg="$zip four arrays two empty with longest", + ), +] + +ALL_TESTS = ( + DEGENERATE_TESTS + + NESTED_ARRAY_TESTS + + NULL_TESTS + + OBJECT_TESTS + + LARGE_ARRAY_TESTS + + MANY_INPUTS_TESTS + + MULTI_LENGTH_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_zip_edge_cases(collection, test): + """Test $zip with empty, single, nested, null, large, and multi-length inputs.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/zip/test_zip_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/zip/test_zip_errors.py new file mode 100644 index 000000000..5aa2a79b6 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/zip/test_zip_errors.py @@ -0,0 +1,443 @@ +""" +Error tests for $zip expression. + +Tests non-array inputs, invalid useLongestLength, invalid defaults, +defaults without useLongestLength, and defaults length mismatch. +""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( # noqa: E501 + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + ZIP_DEFAULTS_LENGTH_MISMATCH_ERROR, + ZIP_DEFAULTS_NOT_ARRAY_ERROR, + ZIP_DEFAULTS_WITHOUT_LONGEST_ERROR, + ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + ZIP_USE_LONGEST_NOT_BOOL_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_MAX, + DECIMAL128_MIN, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_ZERO, + DOUBLE_NEGATIVE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + INT32_MAX, + INT32_MIN, + INT64_MAX, + INT64_MIN, +) + +# Property [Non-Array Element]: $zip rejects non-array elements in inputs with all BSON types. +NOT_ARRAY_ELEMENT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "string_input", + doc={"arr0": "hello", "arr1": [1]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject string input element", + ), + ExpressionTestCase( + "int_input", + doc={"arr0": 42, "arr1": [1]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject int input element", + ), + ExpressionTestCase( + "negative_int_input", + doc={"arr0": -42, "arr1": [1]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject negative int input element", + ), + ExpressionTestCase( + "bool_input", + doc={"arr0": True, "arr1": [1]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject bool input element", + ), + ExpressionTestCase( + "object_input", + doc={"arr0": {"a": 1}, "arr1": [1]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject object input element", + ), + ExpressionTestCase( + "double_input", + doc={"arr0": 3.14, "arr1": [1]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject double input element", + ), + ExpressionTestCase( + "negative_double_input", + doc={"arr0": -3.14, "arr1": [1]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject negative double input element", + ), + ExpressionTestCase( + "decimal128_input", + doc={"arr0": Decimal128("1"), "arr1": [1]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject decimal128 input element", + ), + ExpressionTestCase( + "int64_input", + doc={"arr0": Int64(1), "arr1": [1]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject int64 input element", + ), + ExpressionTestCase( + "objectid_input", + doc={"arr0": ObjectId(), "arr1": [1]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject objectid input element", + ), + ExpressionTestCase( + "datetime_input", + doc={"arr0": datetime(2024, 1, 1, tzinfo=timezone.utc), "arr1": [1]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject datetime input element", + ), + ExpressionTestCase( + "binary_input", + doc={"arr0": Binary(b"x", 0), "arr1": [1]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject binary input element", + ), + ExpressionTestCase( + "regex_input", + doc={"arr0": Regex("x"), "arr1": [1]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject regex input element", + ), + ExpressionTestCase( + "code_input", + doc={"arr0": Code("function(){}"), "arr1": [1]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject javascript code input element", + ), + ExpressionTestCase( + "maxkey_input", + doc={"arr0": MaxKey(), "arr1": [1]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject maxkey input element", + ), + ExpressionTestCase( + "minkey_input", + doc={"arr0": MinKey(), "arr1": [1]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject minkey input element", + ), + ExpressionTestCase( + "timestamp_input", + doc={"arr0": Timestamp(0, 0), "arr1": [1]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject timestamp input element", + ), + ExpressionTestCase( + "non_array_second_position", + doc={"arr0": [1], "arr1": 42}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject non-array in second position", + ), + ExpressionTestCase( + "non_array_middle_position", + doc={"arr0": [1], "arr1": "bad", "arr2": [2]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1", "$arr2"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject non-array in middle position", + ), +] + +# Property [Special Numeric Input]: $zip rejects special numeric values as input. +SPECIAL_NUMERIC_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nan_input", + doc={"arr0": FLOAT_NAN, "arr1": [1]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject NaN input element", + ), + ExpressionTestCase( + "inf_input", + doc={"arr0": FLOAT_INFINITY, "arr1": [1]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject Infinity input element", + ), + ExpressionTestCase( + "neg_inf_input", + doc={"arr0": FLOAT_NEGATIVE_INFINITY, "arr1": [1]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject -Infinity input element", + ), + ExpressionTestCase( + "neg_zero_input", + doc={"arr0": DOUBLE_NEGATIVE_ZERO, "arr1": [1]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject negative zero input element", + ), + ExpressionTestCase( + "decimal128_nan_input", + doc={"arr0": DECIMAL128_NAN, "arr1": [1]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject Decimal128 NaN input element", + ), + ExpressionTestCase( + "decimal128_inf_input", + doc={"arr0": DECIMAL128_INFINITY, "arr1": [1]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject Decimal128 Infinity input element", + ), + ExpressionTestCase( + "decimal128_neg_inf_input", + doc={"arr0": DECIMAL128_NEGATIVE_INFINITY, "arr1": [1]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject Decimal128 -Infinity input element", + ), + ExpressionTestCase( + "decimal128_neg_zero_input", + doc={"arr0": DECIMAL128_NEGATIVE_ZERO, "arr1": [1]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject Decimal128 -0 input element", + ), +] + +# Property [Boundary Input]: $zip rejects numeric boundary values as input. +BOUNDARY_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int32_max_input", + doc={"arr0": INT32_MAX, "arr1": [1]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject INT32_MAX input element", + ), + ExpressionTestCase( + "int32_min_input", + doc={"arr0": INT32_MIN, "arr1": [1]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject INT32_MIN input element", + ), + ExpressionTestCase( + "int64_max_input", + doc={"arr0": INT64_MAX, "arr1": [1]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject INT64_MAX input element", + ), + ExpressionTestCase( + "int64_min_input", + doc={"arr0": INT64_MIN, "arr1": [1]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject INT64_MIN input element", + ), + ExpressionTestCase( + "decimal128_max_input", + doc={"arr0": DECIMAL128_MAX, "arr1": [1]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject DECIMAL128_MAX input element", + ), + ExpressionTestCase( + "decimal128_min_input", + doc={"arr0": DECIMAL128_MIN, "arr1": [1]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject DECIMAL128_MIN input element", + ), +] + +# Property [String Element]: $zip rejects string values as input elements. +STRING_EDGE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "comma_separated_string_input", + doc={"arr0": "1, 2, 3", "arr1": [1]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject comma-separated string", + ), + ExpressionTestCase( + "json_like_string_input", + doc={"arr0": "[1, 2, 3]", "arr1": [1]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject JSON-like string", + ), +] + +# Property [Invalid useLongestLength]: $zip rejects invalid useLongestLength values. +USE_LONGEST_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "use_longest_string", + doc={"arr0": [1], "arr1": [2]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"], "useLongestLength": "true"}}, + error_code=ZIP_USE_LONGEST_NOT_BOOL_ERROR, + msg="$zip should reject string useLongestLength", + ), + ExpressionTestCase( + "use_longest_int", + doc={"arr0": [1], "arr1": [2]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"], "useLongestLength": 1}}, + error_code=ZIP_USE_LONGEST_NOT_BOOL_ERROR, + msg="$zip should reject int useLongestLength", + ), + ExpressionTestCase( + "use_longest_int_0", + doc={"arr0": [1, 2]}, + expression={"$zip": {"inputs": ["$arr0"], "useLongestLength": 0}}, + error_code=ZIP_USE_LONGEST_NOT_BOOL_ERROR, + msg="$zip int 0 should error (not bool)", + ), + ExpressionTestCase( + "use_longest_empty_string", + doc={"arr0": [1, 2]}, + expression={"$zip": {"inputs": ["$arr0"], "useLongestLength": ""}}, + error_code=ZIP_USE_LONGEST_NOT_BOOL_ERROR, + msg="$zip empty string should error (not bool)", + ), + ExpressionTestCase( + "use_longest_array", + doc={"arr0": [1, 2]}, + expression={"$zip": {"inputs": ["$arr0"], "useLongestLength": []}}, + error_code=ZIP_USE_LONGEST_NOT_BOOL_ERROR, + msg="$zip empty array should error (not bool)", + ), + ExpressionTestCase( + "use_longest_object", + doc={"arr0": [1, 2]}, + expression={"$zip": {"inputs": ["$arr0"], "useLongestLength": {"a": 1}}}, + error_code=ZIP_USE_LONGEST_NOT_BOOL_ERROR, + msg="$zip object should error (not bool)", + ), + ExpressionTestCase( + "use_longest_nan", + doc={"arr0": [1, 2]}, + expression={"$zip": {"inputs": ["$arr0"], "useLongestLength": FLOAT_NAN}}, + error_code=ZIP_USE_LONGEST_NOT_BOOL_ERROR, + msg="$zip naN should error (not bool)", + ), + ExpressionTestCase( + "use_longest_infinity", + doc={"arr0": [1, 2]}, + expression={"$zip": {"inputs": ["$arr0"], "useLongestLength": FLOAT_INFINITY}}, + error_code=ZIP_USE_LONGEST_NOT_BOOL_ERROR, + msg="$zip infinity should error (not bool)", + ), +] + +# Property [Invalid Defaults]: $zip rejects invalid defaults values. +DEFAULTS_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "defaults_without_use_longest", + doc={"arr0": [1, 2], "arr1": [3]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"], "defaults": [0, 0]}}, + error_code=ZIP_DEFAULTS_WITHOUT_LONGEST_ERROR, + msg="$zip should reject defaults without useLongestLength", + ), + ExpressionTestCase( + "defaults_without_longest_false", + doc={"arr0": [1], "arr1": [2]}, + expression={ + "$zip": {"inputs": ["$arr0", "$arr1"], "useLongestLength": False, "defaults": [0, 0]} + }, + error_code=ZIP_DEFAULTS_WITHOUT_LONGEST_ERROR, + msg="$zip defaults with useLongestLength false should error", + ), + ExpressionTestCase( + "defaults_length_mismatch", + doc={"arr0": [1, 2], "arr1": [3]}, + expression={ + "$zip": {"inputs": ["$arr0", "$arr1"], "useLongestLength": True, "defaults": [0]} + }, + error_code=ZIP_DEFAULTS_LENGTH_MISMATCH_ERROR, + msg="$zip should reject defaults with wrong length", + ), + ExpressionTestCase( + "defaults_too_many", + doc={"arr0": [1], "arr1": [2]}, + expression={ + "$zip": {"inputs": ["$arr0", "$arr1"], "useLongestLength": True, "defaults": [0, 0, 0]} + }, + error_code=ZIP_DEFAULTS_LENGTH_MISMATCH_ERROR, + msg="$zip should reject defaults longer than inputs", + ), + ExpressionTestCase( + "defaults_not_array", + doc={"arr0": [1], "arr1": [2]}, + expression={ + "$zip": {"inputs": ["$arr0", "$arr1"], "useLongestLength": True, "defaults": "bad"} + }, + error_code=ZIP_DEFAULTS_NOT_ARRAY_ERROR, + msg="$zip should reject non-array defaults", + ), + ExpressionTestCase( + "defaults_not_array_object", + doc={"arr0": [1]}, + expression={"$zip": {"inputs": ["$arr0"], "useLongestLength": True, "defaults": {"a": 1}}}, + error_code=ZIP_DEFAULTS_NOT_ARRAY_ERROR, + msg="$zip defaults as object should error", + ), + ExpressionTestCase( + "defaults_not_array_int", + doc={"arr0": [1]}, + expression={"$zip": {"inputs": ["$arr0"], "useLongestLength": True, "defaults": 1}}, + error_code=ZIP_DEFAULTS_NOT_ARRAY_ERROR, + msg="$zip defaults as int should error", + ), +] + +ALL_INPUT_ELEMENT_TESTS = ( + NOT_ARRAY_ELEMENT_TESTS + + SPECIAL_NUMERIC_ERROR_TESTS + + BOUNDARY_ERROR_TESTS + + STRING_EDGE_ERROR_TESTS + + USE_LONGEST_ERROR_TESTS + + DEFAULTS_ERROR_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_INPUT_ELEMENT_TESTS)) +def test_zip_non_array_input_error(collection, test): + """Test $zip rejects non-array inputs and invalid useLongestLength/defaults.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/zip/test_zip_expressions.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/zip/test_zip_expressions.py new file mode 100644 index 000000000..0e65c949a --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/zip/test_zip_expressions.py @@ -0,0 +1,239 @@ +""" +Expression and field path tests for $zip expression. + +Tests field path lookups, composite paths, system variables, +and null/missing propagation via expressions. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( # noqa: E501 + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ZIP_REQUIRES_ARRAY_ELEMENT_ERROR +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import MISSING + +# Property [Field Path Resolution]: $zip resolves nested and composite field paths. +# Property [Field Lookup]: $zip resolves field paths in expressions. +FIELD_LOOKUP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_field_path", + expression={"$zip": {"inputs": ["$a.b", "$a.c"]}}, + doc={"a": {"b": [1, 2], "c": [3, 4]}}, + expected=[[1, 3], [2, 4]], + msg="$zip should resolve nested field paths", + ), + ExpressionTestCase( + "deeply_nested_field", + expression={"$zip": {"inputs": ["$a.b.c", "$a.b.d"]}}, + doc={"a": {"b": {"c": [10], "d": [20]}}}, + expected=[[10, 20]], + msg="$zip should resolve deeply nested field paths", + ), + ExpressionTestCase( + "nonexistent_field_null", + expression={"$zip": {"inputs": ["$a.nonexistent", "$b"]}}, + doc={"a": {"missing": 1}, "b": [1]}, + expected=None, + msg="$zip non-existent field should propagate null", + ), + ExpressionTestCase( + "same_field_twice", + expression={"$zip": {"inputs": ["$arr", "$arr"]}}, + doc={"arr": [1, 2, 3]}, + expected=[[1, 1], [2, 2], [3, 3]], + msg="$zip same field used twice should zip with itself", + ), +] + +# Property [Composite Paths]: $zip resolves composite array paths from array-of-objects. +COMPOSITE_PATH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "composite_array", + expression={"$zip": {"inputs": ["$x.y", "$x.z"]}}, + doc={"x": [{"y": 1, "z": 10}, {"y": 2, "z": 20}]}, + expected=[[1, 10], [2, 20]], + msg="$zip composite array path from array-of-objects", + ), + ExpressionTestCase( + "array_index_path_resolves_empty", + expression={"$zip": {"inputs": ["$a.0", [1, 2]]}}, + doc={"a": [[1, 2], [3, 4]]}, + expected=[], + msg="$zip array index path resolves to [] (shortest)", + ), +] + +# Property [Variables]: $zip works with $let and system variables. +LET_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "let_variable", + expression={ + "$let": { + "vars": {"a": "$arr1", "b": "$arr2"}, + "in": {"$zip": {"inputs": ["$$a", "$$b"]}}, + } + }, + doc={"arr1": [1, 2], "arr2": [3, 4]}, + expected=[[1, 3], [2, 4]], + msg="$zip should work with $let variables", + ), + ExpressionTestCase( + "root_variable", + expression={"$zip": {"inputs": ["$$ROOT.a", "$$ROOT.b"]}}, + doc={"_id": 1, "a": [1], "b": [2]}, + expected=[[1, 2]], + msg="$zip should work with $$ROOT", + ), + ExpressionTestCase( + "current_variable", + expression={"$zip": {"inputs": ["$$CURRENT.a", "$$CURRENT.b"]}}, + doc={"_id": 2, "a": [1], "b": [2]}, + expected=[[1, 2]], + msg="$$CURRENT should be equivalent to field path", + ), +] + +# Property [Null/Missing Fields]: $zip handles null and missing field paths. +NULL_MISSING_EXPR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "missing_field", + expression={"$zip": {"inputs": ["$nonexistent", [1]]}}, + doc={"other": 1}, + expected=None, + msg="$zip missing field should propagate null", + ), + ExpressionTestCase( + "missing_input_type_is_null", + expression={"$type": {"$zip": {"inputs": ["$nonexistent", [1]]}}}, + doc={"x": 1}, + expected="null", + msg="$zip missing field should produce null type", + ), + ExpressionTestCase( + "remove_variable", + expression={"$zip": {"inputs": ["$$REMOVE", [1]]}}, + doc={"x": 1}, + expected=None, + msg="$$REMOVE propagates null", + ), + ExpressionTestCase( + "field_ref_non_array", + expression={"$zip": {"inputs": ["$a", [1]]}}, + doc={"a": 1}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip field resolving to non-array should error", + ), + ExpressionTestCase( + "missing_first_input", + expression={"$zip": {"inputs": [MISSING, [1, 2]]}}, + doc={"a": 1}, + expected=None, + msg="$zip missing first input returns null", + ), + ExpressionTestCase( + "all_missing_fields", + expression={"$zip": {"inputs": ["$x", "$y"]}}, + doc={"_placeholder": 1}, + expected=None, + msg="$zip all missing fields return null", + ), + ExpressionTestCase( + "explicit_null_field", + expression={"$zip": {"inputs": ["$a", "$b"]}}, + doc={"a": None, "b": [1, 2]}, + expected=None, + msg="$zip explicit null field returns null", + ), + ExpressionTestCase( + "null_with_longest_true", + expression={"$zip": {"inputs": ["$a", [1, 2]], "useLongestLength": True}}, + doc={"a": None}, + expected=None, + msg="$zip null input with longest true returns null", + ), + ExpressionTestCase( + "null_with_defaults", + expression={ + "$zip": {"inputs": ["$a", [1, 2]], "useLongestLength": True, "defaults": [0, 0]} + }, + doc={"a": None}, + expected=None, + msg="$zip null input with defaults still returns null", + ), + ExpressionTestCase( + "missing_field_as_element", + expression={"$zip": {"inputs": [["$not_exist", 2], [1, 2]]}}, + doc={"a": 1}, + expected=[[None, 1], [2, 2]], + msg="$zip missing field as element becomes null", + ), +] + +# Property [Self-Composition]: $zip composes with nested $zip calls. +SELF_COMPOSITION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_zip_full", + expression={ + "$zip": { + "inputs": [ + {"$zip": {"inputs": ["$a", "$b"]}}, + "$c", + ] + } + }, + doc={"a": [1, 2], "b": [3, 4], "c": ["x", "y"]}, + expected=[[[1, 3], "x"], [[2, 4], "y"]], + msg="$zip nested $zip as input works correctly", + ), +] + +# Property [Field Path Elements]: $zip resolves field paths used as array elements. +FIELD_PATH_ELEMENT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "field_path_as_element", + expression={"$zip": {"inputs": [["$a", "$b"], [1, 2]]}}, + doc={"a": 10, "b": 20}, + expected=[[10, 1], [20, 2]], + msg="$zip field paths as elements resolve correctly", + ), + ExpressionTestCase( + "nested_field_path_as_element", + expression={"$zip": {"inputs": [["$foo.bar", "$b"], [1, 2]]}}, + doc={"foo": {"bar": 10}, "b": 20}, + expected=[[10, 1], [20, 2]], + msg="$zip nested field path as element resolves correctly", + ), + ExpressionTestCase( + "field_path_in_defaults", + expression={ + "$zip": {"inputs": ["$a", "$b"], "useLongestLength": True, "defaults": ["$c", "$d"]} + }, + doc={"a": [1, 2, 3], "b": ["x"], "c": "default_a", "d": "default_b"}, + expected=[[1, "x"], [2, "default_b"], [3, "default_b"]], + msg="$zip field paths in defaults resolve from document", + ), +] + +ALL_EXPR_TESTS = ( + FIELD_LOOKUP_TESTS + + COMPOSITE_PATH_TESTS + + LET_TESTS + + NULL_MISSING_EXPR_TESTS + + SELF_COMPOSITION_TESTS + + FIELD_PATH_ELEMENT_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_EXPR_TESTS)) +def test_zip_field_paths_and_variables(collection, test): + """Test $zip with field paths, $let, system variables, and null propagation.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_map.py b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_map.py new file mode 100644 index 000000000..465c12f31 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_map.py @@ -0,0 +1,96 @@ +""" +Combination tests for $map composed with other operators. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( # noqa: E501 + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +MAP_COMBINATION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "map_on_range", + expression={ + "$map": {"input": {"$range": [0, 5]}, "in": {"$multiply": ["$$this", "$$this"]}} + }, + doc={"_placeholder": 1}, + expected=[0, 1, 4, 9, 16], + msg="$map should map squares over $range result", + ), + ExpressionTestCase( + "map_with_concatArrays", + expression={ + "$concatArrays": [ + {"$map": {"input": "$a", "in": {"$multiply": ["$$this", 2]}}}, + {"$map": {"input": "$b", "in": {"$multiply": ["$$this", 3]}}}, + ] + }, + doc={"a": [1, 2], "b": [3, 4]}, + expected=[2, 4, 9, 12], + msg="$map should concatenate two mapped arrays", + ), + ExpressionTestCase( + "map_with_filter", + expression={ + "$map": { + "input": {"$filter": {"input": "$arr", "cond": {"$gt": ["$$this", 2]}}}, + "in": {"$multiply": ["$$this", 10]}, + } + }, + doc={"arr": [1, 2, 3, 4, 5]}, + expected=[30, 40, 50], + msg="$map should map over filtered array", + ), + ExpressionTestCase( + "map_result_into_reduce", + expression={ + "$reduce": { + "input": {"$map": {"input": "$arr", "in": {"$multiply": ["$$this", 2]}}}, + "initialValue": 0, + "in": {"$add": ["$$value", "$$this"]}, + } + }, + doc={"arr": [1, 2, 3]}, + expected=12, + msg="$reduce on $map result should sum doubled values", + ), + ExpressionTestCase( + "map_3_level_nested", + expression={ + "$map": { + "input": [[[1]]], + "as": "a", + "in": { + "$map": { + "input": "$$a", + "as": "b", + "in": { + "$map": { + "input": "$$b", + "in": {"$add": ["$$this", 100]}, + } + }, + } + }, + } + }, + doc={"_placeholder": 1}, + expected=[[[101]]], + msg="$map 3-level nested $map should work", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(MAP_COMBINATION_TESTS)) +def test_map_combination(collection, test): + """Test $map composed with other operators.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_range.py b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_range.py new file mode 100644 index 000000000..8ad13a7ff --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_range.py @@ -0,0 +1,89 @@ +""" +Combination tests for $range composed with other operators. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( # noqa: E501 + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +RANGE_COMBINATION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "reverseArray_on_range", + expression={"$reverseArray": {"$range": ["$start", "$end"]}}, + doc={"start": 0, "end": 5}, + expected=[4, 3, 2, 1, 0], + msg="$reverseArray on $range result", + ), + ExpressionTestCase( + "concatArrays_two_ranges", + expression={"$concatArrays": [{"$range": [0, 3]}, {"$range": [3, 6]}]}, + doc={"x": 1}, + expected=[0, 1, 2, 3, 4, 5], + msg="$concatArrays on two $range results", + ), + ExpressionTestCase( + "in_on_range", + expression={"$in": [5, {"$range": ["$start", "$end"]}]}, + doc={"start": 0, "end": 10}, + expected=True, + msg="$in on $range result", + ), + ExpressionTestCase( + "isArray_on_range", + expression={"$isArray": {"$range": [0, 3]}}, + doc={"x": 1}, + expected=True, + msg="$isArray on $range result should return true", + ), + ExpressionTestCase( + "in_miss_on_range", + expression={"$in": [5, {"$range": [0, 5]}]}, + doc={"x": 1}, + expected=False, + msg="$range 5 should not be in [0..4] (exclusive end)", + ), + ExpressionTestCase( + "self_nesting_start", + expression={"$range": [{"$arrayElemAt": [{"$range": [2, 5]}, 0]}, 10]}, + doc={"x": 1}, + expected=[2, 3, 4, 5, 6, 7, 8, 9], + msg="$range start from inner range", + ), + ExpressionTestCase( + "self_nesting_end", + expression={"$range": [0, {"$size": {"$range": [0, 5]}}]}, + doc={"x": 1}, + expected=[0, 1, 2, 3, 4], + msg="$range end from size of inner range", + ), + ExpressionTestCase( + "indexOfArray_on_range", + expression={"$indexOfArray": [{"$range": [0, 10]}, 7]}, + doc={"x": 1}, + expected=7, + msg="$range index of 7 in range 0..9 should be 7", + ), + ExpressionTestCase( + "output_type_is_int", + expression={"$type": {"$arrayElemAt": [{"$range": [0, 1]}, 0]}}, + doc={"x": 1}, + expected="int", + msg="$range output element should be int type", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(RANGE_COMBINATION_TESTS)) +def test_range_combination(collection, test): + """Test $range composed with other operators.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_zip.py b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_zip.py new file mode 100644 index 000000000..0ac70e1e5 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_zip.py @@ -0,0 +1,126 @@ +""" +Combination tests for $zip composed with other operators. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( # noqa: E501 + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import FLOAT_NAN + +ZIP_COMBINATION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "zip_on_sortArray", + expression={"$zip": {"inputs": [{"$sortArray": {"input": "$a", "sortBy": 1}}, "$b"]}}, + doc={"a": [3, 1, 2], "b": [10, 20, 30]}, + expected=[[1, 10], [2, 20], [3, 30]], + msg="$zip should zip $sortArray result with array", + ), + ExpressionTestCase( + "zip_on_concatArrays", + expression={"$zip": {"inputs": [{"$concatArrays": ["$a", "$b"]}, "$c"]}}, + doc={"a": [1], "b": [2], "c": [10, 20]}, + expected=[[1, 10], [2, 20]], + msg="$zip should zip $concatArrays result with array", + ), + ExpressionTestCase( + "zip_on_range", + expression={"$zip": {"inputs": [{"$range": [0, 3]}, {"$range": [10, 13]}]}}, + doc={"x": 1}, + expected=[[0, 10], [1, 11], [2, 12]], + msg="$zip should zip two $range results", + ), + ExpressionTestCase( + "arrayElemAt_on_zip", + expression={"$arrayElemAt": [{"$zip": {"inputs": ["$a", "$b"]}}, 1]}, + doc={"a": [1, 2, 3], "b": [10, 20, 30]}, + expected=[2, 20], + msg="$arrayElemAt on $zip result", + ), + ExpressionTestCase( + "zip_matrix_transpose_2x3", + expression={ + "$zip": { + "inputs": [ + {"$arrayElemAt": ["$matrix", 0]}, + {"$arrayElemAt": ["$matrix", 1]}, + ] + } + }, + doc={"matrix": [[0, 1, 2], [3, 4, 5]]}, + expected=[[0, 3], [1, 4], [2, 5]], + msg="$zip 2x3 matrix transposed to 3x2", + ), + ExpressionTestCase( + "zip_index_preservation", + expression={"$zip": {"inputs": ["$arr", {"$range": [0, {"$size": "$arr"}]}]}}, + doc={"arr": ["a", "b", "c"]}, + expected=[["a", 0], ["b", 1], ["c", 2]], + msg="$zip elements paired with indices", + ), + ExpressionTestCase( + "zip_reduce_on_output", + expression={ + "$reduce": { + "input": {"$zip": {"inputs": ["$a", "$b"]}}, + "initialValue": 0, + "in": {"$add": ["$$value", {"$arrayElemAt": ["$$this", 1]}]}, + } + }, + doc={"a": [1, 2, 3], "b": [10, 20, 30]}, + expected=60, + msg="$reduce sums second elements of zipped pairs", + ), + ExpressionTestCase( + "zip_output_is_array", + expression={"$isArray": {"$zip": {"inputs": ["$a", "$b"]}}}, + doc={"a": [1, 2], "b": ["a", "b"]}, + expected=True, + msg="$zip output is an array", + ), + ExpressionTestCase( + "float_nan_preserved", + expression={"$arrayElemAt": [{"$arrayElemAt": [{"$zip": {"inputs": ["$a", "$b"]}}, 0]}, 0]}, + doc={"a": [FLOAT_NAN], "b": [1]}, + expected=pytest.approx(FLOAT_NAN, nan_ok=True), + msg="$zip float NaN element preserved after zipping", + ), + ExpressionTestCase( + "default_float_nan", + expression={ + "$arrayElemAt": [ + { + "$arrayElemAt": [ + { + "$zip": { + "inputs": ["$a", "$b"], + "useLongestLength": True, + "defaults": [0, FLOAT_NAN], + } + }, + 1, + ] + }, + 1, + ] + }, + doc={"a": [1, 2], "b": [10]}, + expected=pytest.approx(FLOAT_NAN, nan_ok=True), + msg="$zip should use float NaN as default value", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(ZIP_COMBINATION_TESTS)) +def test_zip_combination(collection, test): + """Test $zip composed with other operators.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) From f5e47ac6fa703a91bfe62c5e9e5137f08f50f92a Mon Sep 17 00:00:00 2001 From: "Alina (Xi) Li" <96995091+alinaliBQ@users.noreply.github.com> Date: Mon, 13 Jul 2026 13:36:01 -0700 Subject: [PATCH 25/35] Add $filter, $in, $indexOfArray, and $isArray tests (#667) Signed-off-by: Alina (Xi) Li Signed-off-by: RyanGarfinkel <113050972+RyanGarfinkel@users.noreply.github.com> --- .../operator/expressions/array/__init__.py | 0 .../expressions/array/filter/__init__.py | 0 .../array/filter/test_filter_as_errors.py | 136 ++++ .../array/filter/test_filter_bson_types.py | 322 +++++++++ .../array/filter/test_filter_core_behavior.py | 424 ++++++++++++ .../array/filter/test_filter_errors.py | 445 ++++++++++++ .../array/filter/test_filter_expressions.py | 170 +++++ .../filter/test_filter_structure_errors.py | 105 +++ ...ression_filter.py => test_smoke_filter.py} | 2 +- .../operator/expressions/array/in/__init__.py | 0 .../array/in/test_in_bson_types.py | 285 ++++++++ .../array/in/test_in_core_behavior.py | 265 +++++++ .../expressions/array/in/test_in_errors.py | 188 +++++ .../array/in/test_in_expressions.py | 113 +++ .../array/in/test_in_nested_arrays.py | 199 ++++++ .../array/in/test_in_null_missing.py | 66 ++ ...moke_expression_in.py => test_smoke_in.py} | 2 +- .../array/indexOfArray/__init__.py | 0 .../test_indexOfArray_bson_types.py | 464 +++++++++++++ .../test_indexOfArray_core_behavior.py | 647 ++++++++++++++++++ .../indexOfArray/test_indexOfArray_errors.py | 524 ++++++++++++++ .../test_indexOfArray_expressions.py | 118 ++++ .../test_indexOfArray_null_missing.py | 107 +++ ...xOfArray.py => test_smoke_indexOfArray.py} | 4 +- .../expressions/array/isArray/__init__.py | 0 .../array/isArray/test_isArray_bson_types.py | 418 +++++++++++ .../isArray/test_isArray_core_behavior.py | 325 +++++++++ .../array/isArray/test_isArray_errors.py | 50 ++ .../array/isArray/test_isArray_expressions.py | 165 +++++ ...ssion_isArray.py => test_smoke_isArray.py} | 2 +- ...array_arrayElemAt_indexOfArray_in_slice.py | 362 ++++++++++ .../test_expressions_combination_filter.py | 65 ++ .../test_expressions_combination_isArray.py | 54 ++ 33 files changed, 6023 insertions(+), 4 deletions(-) create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/filter/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/filter/test_filter_as_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/filter/test_filter_bson_types.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/filter/test_filter_core_behavior.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/filter/test_filter_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/filter/test_filter_expressions.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/filter/test_filter_structure_errors.py rename documentdb_tests/compatibility/tests/core/operator/expressions/array/filter/{test_smoke_expression_filter.py => test_smoke_filter.py} (92%) create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/in/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/in/test_in_bson_types.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/in/test_in_core_behavior.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/in/test_in_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/in/test_in_expressions.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/in/test_in_nested_arrays.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/in/test_in_null_missing.py rename documentdb_tests/compatibility/tests/core/operator/expressions/array/in/{test_smoke_expression_in.py => test_smoke_in.py} (89%) create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/indexOfArray/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/indexOfArray/test_indexOfArray_bson_types.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/indexOfArray/test_indexOfArray_core_behavior.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/indexOfArray/test_indexOfArray_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/indexOfArray/test_indexOfArray_expressions.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/indexOfArray/test_indexOfArray_null_missing.py rename documentdb_tests/compatibility/tests/core/operator/expressions/array/indexOfArray/{test_smoke_expression_indexOfArray.py => test_smoke_indexOfArray.py} (87%) create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/isArray/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/isArray/test_isArray_bson_types.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/isArray/test_isArray_core_behavior.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/isArray/test_isArray_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/isArray/test_isArray_expressions.py rename documentdb_tests/compatibility/tests/core/operator/expressions/array/isArray/{test_smoke_expression_isArray.py => test_smoke_isArray.py} (88%) create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_array_arrayElemAt_indexOfArray_in_slice.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_filter.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_isArray.py diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/filter/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/filter/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/filter/test_filter_as_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/filter/test_filter_as_errors.py new file mode 100644 index 000000000..8e9ba4e4f --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/filter/test_filter_as_errors.py @@ -0,0 +1,136 @@ +""" +Error tests for $filter 'as' parameter. + +Tests invalid 'as' types. +""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, +) +from documentdb_tests.framework.error_codes import FAILED_TO_PARSE_ERROR +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + FLOAT_INFINITY, + FLOAT_NAN, +) + +INVALID_AS_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "type_int", + expression={"$filter": {"input": [1, 2, 3], "as": 1, "cond": True}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$filter should reject int as variable name", + ), + ExpressionTestCase( + "type_long", + expression={"$filter": {"input": [1, 2, 3], "as": Int64(1), "cond": True}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$filter should reject Int64 as variable name", + ), + ExpressionTestCase( + "type_object", + expression={"$filter": {"input": [1, 2, 3], "as": {"a": 1}, "cond": True}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$filter should reject object as variable name", + ), + ExpressionTestCase( + "type_array", + expression={"$filter": {"input": [1, 2, 3], "as": [1], "cond": True}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$filter should reject array as variable name", + ), + ExpressionTestCase( + "type_minkey", + expression={"$filter": {"input": [1, 2, 3], "as": MinKey(), "cond": True}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$filter should reject MinKey as variable name", + ), + ExpressionTestCase( + "type_maxkey", + expression={"$filter": {"input": [1, 2, 3], "as": MaxKey(), "cond": True}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$filter should reject MaxKey as variable name", + ), + ExpressionTestCase( + "type_bindata", + expression={"$filter": {"input": [1, 2, 3], "as": Binary(b"\x00", 0), "cond": True}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$filter should reject Binary as variable name", + ), + ExpressionTestCase( + "type_objectid", + expression={"$filter": {"input": [1, 2, 3], "as": ObjectId(), "cond": True}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$filter should reject ObjectId as variable name", + ), + ExpressionTestCase( + "type_date", + expression={ + "$filter": { + "input": [1, 2, 3], + "as": datetime(2024, 1, 1, tzinfo=timezone.utc), + "cond": True, + } + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="$filter should reject datetime as variable name", + ), + ExpressionTestCase( + "type_timestamp", + expression={"$filter": {"input": [1, 2, 3], "as": Timestamp(0, 0), "cond": True}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$filter should reject Timestamp as variable name", + ), + ExpressionTestCase( + "type_regex", + expression={"$filter": {"input": [1, 2, 3], "as": Regex("pattern"), "cond": True}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$filter should reject Regex as variable name", + ), + ExpressionTestCase( + "type_bool_true", + expression={"$filter": {"input": [1, 2, 3], "as": True, "cond": True}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$filter should reject True as variable name", + ), + ExpressionTestCase( + "type_null", + expression={"$filter": {"input": [1, 2, 3], "as": None, "cond": True}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$filter should reject None as variable name", + ), + ExpressionTestCase( + "type_empty_string", + expression={"$filter": {"input": [1, 2, 3], "as": "", "cond": True}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$filter should reject '' as variable name", + ), + ExpressionTestCase( + "type_nan", + expression={"$filter": {"input": [1, 2, 3], "as": FLOAT_NAN, "cond": True}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$filter should reject NaN as variable name", + ), + ExpressionTestCase( + "type_infinity", + expression={"$filter": {"input": [1, 2, 3], "as": FLOAT_INFINITY, "cond": True}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$filter should reject inf as variable name", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(INVALID_AS_TYPE_TESTS)) +def test_filter_invalid_as(collection, test): + """Test $filter with invalid 'as' parameter values.""" + result = execute_expression(collection, test.expression) + assert_expression_result(result, error_code=test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/filter/test_filter_bson_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/filter/test_filter_bson_types.py new file mode 100644 index 000000000..2b15aa58d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/filter/test_filter_bson_types.py @@ -0,0 +1,322 @@ +""" +BSON type element preservation tests for $filter expression. + +Tests that various BSON types are preserved when filtering arrays +(using a cond that keeps all elements), including special numeric values +and boundary values. +""" + +from datetime import datetime, timezone +from uuid import UUID + +import pytest +from bson import Binary, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ONE_AND_HALF, + DECIMAL128_TRAILING_ZERO, + DECIMAL128_TWO_AND_HALF, + DOUBLE_NEGATIVE_ZERO, + FLOAT_INFINITY, + FLOAT_NEGATIVE_INFINITY, + INT32_MAX, + INT32_MIN, + INT64_MAX, + INT64_MIN, +) + +# BSON types preserved +BSON_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int64_values", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": [Int64(1), Int64(2), Int64(3)]}, + expected=[Int64(1), Int64(2), Int64(3)], + msg="$filter should preserve Int64 values", + ), + ExpressionTestCase( + "decimal128_values", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": [DECIMAL128_ONE_AND_HALF, DECIMAL128_TWO_AND_HALF]}, + expected=[DECIMAL128_ONE_AND_HALF, DECIMAL128_TWO_AND_HALF], + msg="$filter should preserve Decimal128 values", + ), + ExpressionTestCase( + "datetime_values", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={ + "arr": [ + datetime(2024, 1, 1, tzinfo=timezone.utc), + datetime(2024, 6, 1, tzinfo=timezone.utc), + ] + }, + expected=[ + datetime(2024, 1, 1, tzinfo=timezone.utc), + datetime(2024, 6, 1, tzinfo=timezone.utc), + ], + msg="$filter should preserve datetime values", + ), + ExpressionTestCase( + "objectid_values", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": [ObjectId("000000000000000000000001"), ObjectId("000000000000000000000002")]}, + expected=[ObjectId("000000000000000000000001"), ObjectId("000000000000000000000002")], + msg="$filter should preserve ObjectId values", + ), + ExpressionTestCase( + "binary_values", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": [Binary(b"\x01", 0), Binary(b"\x02", 0)]}, + expected=[b"\x01", b"\x02"], + msg="$filter should preserve Binary values", + ), + ExpressionTestCase( + "binary_subtype_preservation", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": [Binary(b"\x01", 128), Binary(b"\x02", 128)]}, + expected=[Binary(b"\x01", 128), Binary(b"\x02", 128)], + msg="$filter should preserve Binary subtype", + ), + ExpressionTestCase( + "regex_values", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": [Regex("^a", "i"), Regex("^b", "i")]}, + expected=[Regex("^a", "i"), Regex("^b", "i")], + msg="$filter should preserve Regex values", + ), + ExpressionTestCase( + "timestamp_values", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": [Timestamp(1, 0), Timestamp(2, 0)]}, + expected=[Timestamp(1, 0), Timestamp(2, 0)], + msg="$filter should preserve Timestamp values", + ), + ExpressionTestCase( + "minkey_maxkey", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": [MinKey(), MaxKey()]}, + expected=[MinKey(), MaxKey()], + msg="$filter should preserve MinKey/MaxKey values", + ), + ExpressionTestCase( + "uuid_values", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={ + "arr": [ + Binary.from_uuid(UUID("01234567-89ab-cdef-fedc-ba9876543210")), + Binary.from_uuid(UUID("fedcba98-7654-3210-0123-456789abcdef")), + ] + }, + expected=[ + Binary.from_uuid(UUID("01234567-89ab-cdef-fedc-ba9876543210")), + Binary.from_uuid(UUID("fedcba98-7654-3210-0123-456789abcdef")), + ], + msg="$filter should preserve UUID binary values", + ), +] + +# Mixed BSON types +MIXED_BSON_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "mixed_bson_types", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": [1, "two", Int64(3), Decimal128("4"), True, None, MinKey()]}, + expected=[1, "two", Int64(3), Decimal128("4"), True, None, MinKey()], + msg="$filter should preserve mixed BSON types", + ), + ExpressionTestCase( + "mixed_dates_and_ids", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={ + "arr": [ + datetime(2024, 1, 1, tzinfo=timezone.utc), + ObjectId("000000000000000000000001"), + Timestamp(1, 0), + ] + }, + expected=[ + datetime(2024, 1, 1, tzinfo=timezone.utc), + ObjectId("000000000000000000000001"), + Timestamp(1, 0), + ], + msg="$filter should preserve dates, ObjectIds, timestamps", + ), +] + +# Special numeric values as elements +SPECIAL_NUMERIC_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "infinity_values", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": [FLOAT_INFINITY, FLOAT_NEGATIVE_INFINITY]}, + expected=[FLOAT_INFINITY, FLOAT_NEGATIVE_INFINITY], + msg="$filter should preserve infinity values", + ), + ExpressionTestCase( + "decimal128_infinity", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": [DECIMAL128_INFINITY, DECIMAL128_NEGATIVE_INFINITY]}, + expected=[DECIMAL128_INFINITY, DECIMAL128_NEGATIVE_INFINITY], + msg="$filter should preserve Decimal128 infinity values", + ), + ExpressionTestCase( + "boundary_values", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": [INT32_MIN, INT32_MAX, INT64_MIN, INT64_MAX]}, + expected=[INT32_MIN, INT32_MAX, INT64_MIN, INT64_MAX], + msg="$filter should preserve numeric boundary values", + ), + ExpressionTestCase( + "negative_zero", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": [DOUBLE_NEGATIVE_ZERO, DECIMAL128_NEGATIVE_ZERO]}, + expected=[DOUBLE_NEGATIVE_ZERO, DECIMAL128_NEGATIVE_ZERO], + msg="$filter should preserve negative zero values", + ), +] + +# Decimal128 precision preservation +DECIMAL128_PRECISION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "decimal128_trailing_zeros", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": [DECIMAL128_TRAILING_ZERO, Decimal128("1.00"), Decimal128("1.000")]}, + expected=[DECIMAL128_TRAILING_ZERO, Decimal128("1.00"), Decimal128("1.000")], + msg="$filter decimal128 trailing zeros preserved", + ), + ExpressionTestCase( + "decimal128_nan", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": [DECIMAL128_NAN, Decimal128("1")]}, + expected=[DECIMAL128_NAN, Decimal128("1")], + msg="$filter decimal128 NaN preserved", + ), +] + +# BSON type filtering with $eq condition +BSON_FILTER_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "filter_int64", + expression={"$filter": {"input": "$arr", "cond": {"$eq": ["$$this", Int64(2)]}}}, + doc={"arr": [Int64(1), Int64(2), Int64(3)]}, + expected=[Int64(2)], + msg="$filter should filter and preserve Int64", + ), + ExpressionTestCase( + "filter_decimal128", + expression={ + "$filter": {"input": "$arr", "cond": {"$eq": ["$$this", DECIMAL128_TWO_AND_HALF]}} + }, + doc={"arr": [DECIMAL128_ONE_AND_HALF, DECIMAL128_TWO_AND_HALF]}, + expected=[DECIMAL128_TWO_AND_HALF], + msg="$filter should filter and preserve Decimal128", + ), + ExpressionTestCase( + "filter_datetime", + expression={ + "$filter": { + "input": "$arr", + "cond": {"$eq": ["$$this", datetime(2024, 6, 1, tzinfo=timezone.utc)]}, + } + }, + doc={ + "arr": [ + datetime(2024, 1, 1, tzinfo=timezone.utc), + datetime(2024, 6, 1, tzinfo=timezone.utc), + ] + }, + expected=[datetime(2024, 6, 1, tzinfo=timezone.utc)], + msg="$filter should filter and preserve datetime", + ), + ExpressionTestCase( + "filter_objectid", + expression={ + "$filter": { + "input": "$arr", + "cond": {"$eq": ["$$this", ObjectId("000000000000000000000001")]}, + } + }, + doc={"arr": [ObjectId("000000000000000000000001"), ObjectId("000000000000000000000002")]}, + expected=[ObjectId("000000000000000000000001")], + msg="$filter should filter and preserve ObjectId", + ), + ExpressionTestCase( + "filter_binary_subtype", + expression={ + "$filter": {"input": "$arr", "cond": {"$eq": ["$$this", Binary(b"\x02", 128)]}} + }, + doc={"arr": [Binary(b"\x01", 128), Binary(b"\x02", 128)]}, + expected=[Binary(b"\x02", 128)], + msg="$filter should filter and preserve Binary subtype", + ), + ExpressionTestCase( + "filter_timestamp", + expression={"$filter": {"input": "$arr", "cond": {"$eq": ["$$this", Timestamp(2, 0)]}}}, + doc={"arr": [Timestamp(1, 0), Timestamp(2, 0)]}, + expected=[Timestamp(2, 0)], + msg="$filter should filter and preserve Timestamp", + ), + ExpressionTestCase( + "filter_regex", + expression={"$filter": {"input": "$arr", "cond": {"$eq": ["$$this", Regex("^b", "i")]}}}, + doc={"arr": [Regex("^a", "i"), Regex("^b", "i")]}, + expected=[Regex("^b", "i")], + msg="$filter should filter and preserve Regex", + ), + ExpressionTestCase( + "filter_minkey", + expression={"$filter": {"input": "$arr", "cond": {"$eq": ["$$this", MinKey()]}}}, + doc={"arr": [MinKey(), MaxKey()]}, + expected=[MinKey()], + msg="$filter should filter and preserve MinKey", + ), + ExpressionTestCase( + "filter_decimal128_nan_not_gte", + expression={"$filter": {"input": "$arr", "cond": {"$gte": ["$$this", 1]}}}, + doc={"arr": [DECIMAL128_NAN]}, + expected=[], + msg="$filter decimal128 NaN not >= 1", + ), + ExpressionTestCase( + "filter_decimal128_neg_inf_not_gte", + expression={"$filter": {"input": "$arr", "cond": {"$gte": ["$$this", 1]}}}, + doc={"arr": [DECIMAL128_NEGATIVE_INFINITY]}, + expected=[], + msg="$filter decimal128 -Infinity not >= 1", + ), + ExpressionTestCase( + "filter_decimal128_inf_gte", + expression={"$filter": {"input": "$arr", "cond": {"$gte": ["$$this", 1]}}}, + doc={"arr": [DECIMAL128_INFINITY]}, + expected=[DECIMAL128_INFINITY], + msg="$filter decimal128 Infinity >= 1", + ), +] + +# Aggregate and test +ALL_BSON_TESTS = ( + BSON_TYPE_TESTS + + MIXED_BSON_TESTS + + SPECIAL_NUMERIC_TESTS + + DECIMAL128_PRECISION_TESTS + + BSON_FILTER_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_BSON_TESTS)) +def test_filter_bson_insert(collection, test): + """Test $filter BSON types with values from inserted documents.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result(result, expected=test.expected, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/filter/test_filter_core_behavior.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/filter/test_filter_core_behavior.py new file mode 100644 index 000000000..5c1db1f96 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/filter/test_filter_core_behavior.py @@ -0,0 +1,424 @@ +""" +Core behavior tests for $filter expression. + +Tests basic filtering, empty arrays, null propagation, custom 'as' variable, +various condition expressions, nested arrays, limit parameter, objects as +elements, and large arrays. +""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_ZERO, + DOUBLE_ZERO, + INT32_MAX, + INT64_ZERO, +) + +# Success: basic filtering +BASIC_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "gt_filter", + expression={"$filter": {"input": "$arr", "cond": {"$gt": ["$$this", 3]}}}, + doc={"arr": [1, 2, 3, 4, 5]}, + expected=[4, 5], + msg="$filter should keep elements greater than 3", + ), + ExpressionTestCase( + "gte_filter", + expression={"$filter": {"input": "$arr", "cond": {"$gte": ["$$this", 3]}}}, + doc={"arr": [1, 2, 3, 4, 5]}, + expected=[3, 4, 5], + msg="$filter should keep elements >= 3", + ), + ExpressionTestCase( + "lt_filter", + expression={"$filter": {"input": "$arr", "cond": {"$lt": ["$$this", 3]}}}, + doc={"arr": [1, 2, 3, 4, 5]}, + expected=[1, 2], + msg="$filter should keep elements less than 3", + ), + ExpressionTestCase( + "eq_filter", + expression={"$filter": {"input": "$arr", "cond": {"$eq": ["$$this", 2]}}}, + doc={"arr": [1, 2, 3, 2, 1]}, + expected=[2, 2], + msg="$filter should keep elements equal to 2", + ), + ExpressionTestCase( + "ne_filter", + expression={"$filter": {"input": "$arr", "cond": {"$ne": ["$$this", 2]}}}, + doc={"arr": [1, 2, 3, 2, 1]}, + expected=[1, 3, 1], + msg="$filter should keep elements not equal to 2", + ), + ExpressionTestCase( + "none_match", + expression={"$filter": {"input": "$arr", "cond": {"$gt": ["$$this", 100]}}}, + doc={"arr": [1, 2, 3]}, + expected=[], + msg="$filter should return empty when none match", + ), + ExpressionTestCase( + "string_filter", + expression={"$filter": {"input": "$arr", "cond": {"$eq": ["$$this", "abcd"]}}}, + doc={"arr": ["abcd", "efgh", "abcd", "zyz"]}, + expected=["abcd", "abcd"], + msg="$filter should filter strings abcd", + ), + ExpressionTestCase( + "bool_cond_true", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": [1, 2, 3]}, + expected=[1, 2, 3], + msg="$filter literal true cond should keep all elements", + ), + ExpressionTestCase( + "bool_cond_false", + expression={"$filter": {"input": "$arr", "cond": False}}, + doc={"arr": [1, 2, 3]}, + expected=[], + msg="$filter literal false cond should keep no elements", + ), + ExpressionTestCase( + "empty_array", + expression={"$filter": {"input": "$arr", "cond": {"$gt": ["$$this", 0]}}}, + doc={"arr": []}, + expected=[], + msg="$filter should return empty array for empty input", + ), + ExpressionTestCase( + "null_input", + expression={"$filter": {"input": "$arr", "cond": {"$gt": ["$$this", 0]}}}, + doc={"arr": None}, + expected=None, + msg="$filter should return null when input is null", + ), + ExpressionTestCase( + "custom_as_var", + expression={"$filter": {"input": "$arr", "as": "item", "cond": {"$gt": ["$$item", 3]}}}, + doc={"arr": [1, 2, 3, 4, 5]}, + expected=[4, 5], + msg="$filter should use custom 'as' variable name", + ), + ExpressionTestCase( + "single_element_match", + expression={"$filter": {"input": "$arr", "cond": {"$gt": ["$$this", 0]}}}, + doc={"arr": [42]}, + expected=[42], + msg="$filter should keep single matching element", + ), + ExpressionTestCase( + "large_array_1000", + expression={"$filter": {"input": "$arr", "cond": {"$gte": ["$$this", 500]}}}, + doc={"arr": list(range(1000))}, + expected=list(range(500, 1000)), + msg="$filter should filter large array", + ), +] + +# Success: nested arrays (filter does not recurse) +NESTED_ARRAY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_arrays_by_size", + expression={"$filter": {"input": "$arr", "cond": {"$gt": [{"$size": "$$this"}, 1]}}}, + doc={"arr": [[1, 2], [3, 4, 5], [], [6]]}, + expected=[[1, 2], [3, 4, 5]], + msg="$filter should filter subarrays by size", + ), + ExpressionTestCase( + "nested_arrays_identity", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": [[1], [2], [3]]}, + expected=[[1], [2], [3]], + msg="$filter should preserve nested arrays when all match", + ), +] + +# Success: elements with null +NULL_ELEMENT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "filter_out_nulls", + expression={"$filter": {"input": "$arr", "cond": {"$ne": ["$$this", None]}}}, + doc={"arr": [1, None, 2, None, 3]}, + expected=[1, 2, 3], + msg="$filter should filter out null elements", + ), + ExpressionTestCase( + "keep_only_nulls", + expression={"$filter": {"input": "$arr", "cond": {"$eq": ["$$this", None]}}}, + doc={"arr": [1, None, 2, None]}, + expected=[None, None], + msg="$filter should keep only null elements", + ), +] + +# Success: objects as elements +OBJECT_ELEMENT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "filter_objects_by_nested_field", + expression={"$filter": {"input": "$arr", "cond": {"$gt": ["$$this.a.b", 2]}}}, + doc={"arr": [{"a": {"b": 1}}, {"a": {"b": 5}}, {"a": {"b": 3}}]}, + expected=[{"a": {"b": 5}}, {"a": {"b": 3}}], + msg="$filter should filter objects by nested field", + ), + ExpressionTestCase( + "duplicate_objects", + expression={"$filter": {"input": "$arr", "cond": {"$eq": ["$$this.a", 1]}}}, + doc={"arr": [{"a": 1}, {"a": 1}, {"a": 2}]}, + expected=[{"a": 1}, {"a": 1}], + msg="$filter should return all matching duplicate objects", + ), + ExpressionTestCase( + "single_object_no_match", + expression={"$filter": {"input": "$arr", "cond": {"$gt": ["$$this.a", 10]}}}, + doc={"arr": [{"a": 1}]}, + expected=[], + msg="$filter should return empty array when single object does not match", + ), +] + +# Success: limit parameter +LIMIT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "limit_1", + expression={"$filter": {"input": "$arr", "cond": {"$gt": ["$$this", 2]}, "limit": 1}}, + doc={"arr": [1, 2, 3, 4, 5]}, + expected=[3], + msg="$filter should return at most 1 matching element", + ), + ExpressionTestCase( + "limit_2", + expression={"$filter": {"input": "$arr", "cond": {"$gt": ["$$this", 2]}, "limit": 2}}, + doc={"arr": [1, 2, 3, 4, 5]}, + expected=[3, 4], + msg="$filter should return at most 2 matching elements", + ), + ExpressionTestCase( + "limit_exceeds_matches", + expression={"$filter": {"input": "$arr", "cond": {"$gt": ["$$this", 3]}, "limit": 10}}, + doc={"arr": [1, 2, 3, 4, 5]}, + expected=[4, 5], + msg="$filter limit > matches should return all matches", + ), + ExpressionTestCase( + "limit_on_empty", + expression={"$filter": {"input": "$arr", "cond": True, "limit": 5}}, + doc={"arr": []}, + expected=[], + msg="$filter limit on empty array returns empty", + ), + ExpressionTestCase( + "limit_with_none_matching", + expression={"$filter": {"input": "$arr", "cond": {"$gt": ["$$this", 100]}, "limit": 2}}, + doc={"arr": [1, 2, 3]}, + expected=[], + msg="$filter limit with no matches returns empty", + ), + ExpressionTestCase( + "limit_long", + expression={ + "$filter": {"input": "$arr", "cond": {"$gt": ["$$this", 2]}, "limit": Int64(1)} + }, + doc={"arr": [1, 2, 3, 4, 5]}, + expected=[3], + msg="$filter long limit should work", + ), + ExpressionTestCase( + "limit_double_whole", + expression={"$filter": {"input": "$arr", "cond": {"$gt": ["$$this", 2]}, "limit": 1.0}}, + doc={"arr": [1, 2, 3, 4, 5]}, + expected=[3], + msg="$filter whole-number double limit should work", + ), + ExpressionTestCase( + "limit_decimal128", + expression={ + "$filter": {"input": "$arr", "cond": {"$gt": ["$$this", 2]}, "limit": Decimal128("1")} + }, + doc={"arr": [1, 2, 3, 4, 5]}, + expected=[3], + msg="$filter decimal128 limit should work", + ), + ExpressionTestCase( + "limit_with_duplicates", + expression={"$filter": {"input": "$arr", "cond": {"$eq": ["$$this", 2]}, "limit": 2}}, + doc={"arr": [2, 2, 2]}, + expected=[2, 2], + msg="$filter limit 2 with duplicates returns first 2 matches", + ), + ExpressionTestCase( + "limit_int32_max", + expression={"$filter": {"input": "$arr", "cond": True, "limit": INT32_MAX}}, + doc={"arr": [1, 2, 3]}, + expected=[1, 2, 3], + msg="$filter iNT32_MAX limit should return all matches", + ), + ExpressionTestCase( + "limit_null", + expression={"$filter": {"input": "$arr", "cond": True, "limit": None}}, + doc={"arr": [1, 2, 3]}, + expected=[1, 2, 3], + msg="$filter null limit should return all matches", + ), + ExpressionTestCase( + "limit_missing_field_ref", + expression={"$filter": {"input": "$arr", "cond": True, "limit": "$nonexistent"}}, + doc={"arr": [1, 2, 3]}, + expected=[1, 2, 3], + msg="$filter missing field reference for limit should return all matches", + ), +] + +# Success: type-based filtering +TYPE_FILTER_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "filter_by_type", + expression={"$filter": {"input": "$arr", "cond": {"$eq": [{"$type": "$$this"}, "int"]}}}, + doc={"arr": [1, "two", True, None, [5], {"a": 1}]}, + expected=[1], + msg="$filter should filter by BSON type", + ), + ExpressionTestCase( + "filter_strings_only", + expression={"$filter": {"input": "$arr", "cond": {"$eq": [{"$type": "$$this"}, "string"]}}}, + doc={"arr": [1, "good", 2, "morning", True]}, + expected=["good", "morning"], + msg="$filter should keep only string elements", + ), +] + +# Success: cond true or false +COND_FALSY_TRUTHY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "cond_nonzero_truthy", + expression={"$filter": {"input": "$arr", "cond": "$$this"}}, + doc={"arr": [0, 1, 2, 0, 3]}, + expected=[1, 2, 3], + msg="$filter non-zero values are truthy", + ), + ExpressionTestCase( + "cond_null_falsy", + expression={"$filter": {"input": "$arr", "cond": "$$this"}}, + doc={"arr": [1, None, 2, None]}, + expected=[1, 2], + msg="$filter null is falsy", + ), + ExpressionTestCase( + "cond_zero_falsy", + expression={"$filter": {"input": "$arr", "cond": 0}}, + doc={"arr": [1, 2]}, + expected=[], + msg="$filter 0 is falsy", + ), + ExpressionTestCase( + "cond_empty_string_truthy", + expression={"$filter": {"input": "$arr", "cond": ""}}, + doc={"arr": [1, 2]}, + expected=[1, 2], + msg="$filter empty string is truthy in MongoDB", + ), + ExpressionTestCase( + "cond_object_truthy", + expression={"$filter": {"input": "$arr", "cond": {"x": 10}}}, + doc={"arr": [1, 2]}, + expected=[1, 2], + msg="$filter object is truthy", + ), + ExpressionTestCase( + "cond_empty_object_truthy", + expression={"$filter": {"input": "$arr", "cond": {}}}, + doc={"arr": [1, 2]}, + expected=[1, 2], + msg="$filter empty object is truthy", + ), + ExpressionTestCase( + "cond_empty_array_truthy", + expression={"$filter": {"input": "$arr", "cond": []}}, + doc={"arr": [1, 2]}, + expected=[1, 2], + msg="$filter empty array is truthy", + ), +] + + +# Success: type strict equality +TYPE_STRICT_EQUALITY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "false_vs_zero", + expression={"$filter": {"input": "$arr", "cond": {"$eq": ["$$this", False]}}}, + doc={"arr": [False, 0, 1, True]}, + expected=[False], + msg="$eq false should match only false, not 0", + ), + ExpressionTestCase( + "true_vs_one", + expression={"$filter": {"input": "$arr", "cond": {"$eq": ["$$this", True]}}}, + doc={"arr": [True, 1, 0, False]}, + expected=[True], + msg="$eq true should match only true, not 1", + ), + ExpressionTestCase( + "empty_string_vs_null", + expression={"$filter": {"input": "$arr", "cond": {"$eq": ["$$this", ""]}}}, + doc={"arr": ["", None, "a"]}, + expected=[""], + msg="$eq '' should match only empty string, not null", + ), + ExpressionTestCase( + "null_vs_zero_false_empty_string", + expression={"$filter": {"input": "$arr", "cond": {"$eq": ["$$this", None]}}}, + doc={"arr": [None, 0, False, ""]}, + expected=[None], + msg="$eq null should match only null, not 0, false, or empty string", + ), +] + +# Success: numeric equivalence +NUMERIC_EQUIVALENCE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "numeric_equivalence_one", + expression={"$filter": {"input": "$arr", "cond": {"$eq": ["$$this", 1]}}}, + doc={"arr": [1, Int64(1), 1.0, Decimal128("1")]}, + expected=[1, Int64(1), 1.0, Decimal128("1")], + msg="$filter all numeric representations of 1 should match", + ), + ExpressionTestCase( + "numeric_equivalence_zero", + expression={"$filter": {"input": "$arr", "cond": {"$eq": ["$$this", 0]}}}, + doc={"arr": [0, INT64_ZERO, DOUBLE_ZERO, DECIMAL128_ZERO]}, + expected=[0, INT64_ZERO, DOUBLE_ZERO, DECIMAL128_ZERO], + msg="$filter all numeric representations of 0 should match", + ), +] + +# Aggregate and test +ALL_TESTS = ( + BASIC_TESTS + + NESTED_ARRAY_TESTS + + NULL_ELEMENT_TESTS + + OBJECT_ELEMENT_TESTS + + LIMIT_TESTS + + TYPE_FILTER_TESTS + + COND_FALSY_TRUTHY_TESTS + + TYPE_STRICT_EQUALITY_TESTS + + NUMERIC_EQUIVALENCE_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_filter_insert(collection, test): + """Test $filter with values from inserted documents.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/filter/test_filter_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/filter/test_filter_errors.py new file mode 100644 index 000000000..c658b98ed --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/filter/test_filter_errors.py @@ -0,0 +1,445 @@ +""" +Error tests for $filter expression. + +Tests non-array input (all BSON types, special numeric values, boundary values) +and limit parameter validation. +Note: $filter propagates null — null input returns null (tested in core_behavior). +""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + FILTER_INPUT_NOT_ARRAY_ERROR, + FILTER_LIMIT_NOT_INTEGRAL_ERROR, + FILTER_LIMIT_NOT_POSITIVE_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_HALF, + DECIMAL128_INFINITY, + DECIMAL128_MAX, + DECIMAL128_MIN, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_NAN, + DECIMAL128_NEGATIVE_ZERO, + DOUBLE_NEGATIVE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + INT32_MAX, + INT32_MIN, + INT64_MAX, + INT64_MIN, +) + +# Error: non-array input — standard BSON types +NOT_ARRAY_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "string_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": "hello"}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="$filter should reject string input", + ), + ExpressionTestCase( + "int_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": 42}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="$filter should reject int input", + ), + ExpressionTestCase( + "negative_int_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": -42}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="$filter should reject negative int input", + ), + ExpressionTestCase( + "bool_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": True}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="$filter should reject bool input", + ), + ExpressionTestCase( + "bool_false_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": False}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="$filter should reject bool false input", + ), + ExpressionTestCase( + "object_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": {"a": 1}}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="$filter should reject object input", + ), + ExpressionTestCase( + "double_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": 3.14}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="$filter should reject double input", + ), + ExpressionTestCase( + "negative_double_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": -3.14}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="$filter should reject negative double input", + ), + ExpressionTestCase( + "decimal128_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": Decimal128("1")}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="$filter should reject decimal128 input", + ), + ExpressionTestCase( + "int64_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": Int64(1)}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="$filter should reject int64 input", + ), + ExpressionTestCase( + "objectid_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": ObjectId()}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="$filter should reject objectid input", + ), + ExpressionTestCase( + "datetime_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="$filter should reject datetime input", + ), + ExpressionTestCase( + "binary_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": Binary(b"x", 0)}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="$filter should reject binary input", + ), + ExpressionTestCase( + "regex_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": Regex("x")}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="$filter should reject regex input", + ), + ExpressionTestCase( + "maxkey_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": MaxKey()}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="$filter should reject maxkey input", + ), + ExpressionTestCase( + "minkey_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": MinKey()}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="$filter should reject minkey input", + ), + ExpressionTestCase( + "timestamp_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": Timestamp(0, 0)}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="$filter should reject timestamp input", + ), +] + +# Error: special float/Decimal128 values +SPECIAL_NUMERIC_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nan_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": FLOAT_NAN}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="$filter should reject NaN input", + ), + ExpressionTestCase( + "inf_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": FLOAT_INFINITY}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="$filter should reject Infinity input", + ), + ExpressionTestCase( + "neg_inf_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": FLOAT_NEGATIVE_INFINITY}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="$filter should reject -Infinity input", + ), + ExpressionTestCase( + "neg_zero_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": DOUBLE_NEGATIVE_ZERO}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="$filter should reject negative zero input", + ), + ExpressionTestCase( + "decimal128_nan_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": DECIMAL128_NAN}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="$filter should reject Decimal128 NaN input", + ), + ExpressionTestCase( + "decimal128_neg_nan_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": DECIMAL128_NEGATIVE_NAN}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="$filter should reject Decimal128 -NaN input", + ), + ExpressionTestCase( + "decimal128_inf_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": DECIMAL128_INFINITY}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="$filter should reject Decimal128 Infinity input", + ), + ExpressionTestCase( + "decimal128_neg_inf_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": DECIMAL128_NEGATIVE_INFINITY}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="$filter should reject Decimal128 -Infinity input", + ), + ExpressionTestCase( + "decimal128_neg_zero_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": DECIMAL128_NEGATIVE_ZERO}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="$filter should reject Decimal128 -0 input", + ), +] + +# Error: numeric boundary values +BOUNDARY_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int32_max_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": INT32_MAX}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="$filter should reject INT32_MAX input", + ), + ExpressionTestCase( + "int32_min_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": INT32_MIN}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="$filter should reject INT32_MIN input", + ), + ExpressionTestCase( + "int64_max_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": INT64_MAX}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="$filter should reject INT64_MAX input", + ), + ExpressionTestCase( + "int64_min_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": INT64_MIN}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="$filter should reject INT64_MIN input", + ), + ExpressionTestCase( + "decimal128_max_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": DECIMAL128_MAX}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="$filter should reject DECIMAL128_MAX input", + ), + ExpressionTestCase( + "decimal128_min_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": DECIMAL128_MIN}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="$filter should reject DECIMAL128_MIN input", + ), +] + +# Error: invalid limit types +INVALID_LIMIT_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "string_limit", + expression={"$filter": {"input": "$arr", "cond": True, "limit": "1"}}, + doc={"arr": [1, 2, 3]}, + error_code=FILTER_LIMIT_NOT_INTEGRAL_ERROR, + msg="$filter string limit should error", + ), + ExpressionTestCase( + "bool_limit", + expression={"$filter": {"input": "$arr", "cond": True, "limit": True}}, + doc={"arr": [1, 2, 3]}, + error_code=FILTER_LIMIT_NOT_INTEGRAL_ERROR, + msg="$filter bool limit should error", + ), + ExpressionTestCase( + "object_limit", + expression={"$filter": {"input": "$arr", "cond": True, "limit": {"a": 1}}}, + doc={"arr": [1, 2, 3]}, + error_code=FILTER_LIMIT_NOT_INTEGRAL_ERROR, + msg="$filter object limit should error", + ), + ExpressionTestCase( + "array_limit", + expression={"$filter": {"input": "$arr", "cond": True, "limit": [1]}}, + doc={"arr": [1, 2, 3]}, + error_code=FILTER_LIMIT_NOT_INTEGRAL_ERROR, + msg="$filter array limit should error", + ), +] + +# Error: invalid limit numeric values +INVALID_LIMIT_NUMERIC_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "zero_limit", + expression={"$filter": {"input": "$arr", "cond": True, "limit": 0}}, + doc={"arr": [1, 2, 3]}, + error_code=FILTER_LIMIT_NOT_POSITIVE_ERROR, + msg="$filter limit 0 should error", + ), + ExpressionTestCase( + "neg_int_limit", + expression={"$filter": {"input": "$arr", "cond": True, "limit": -1}}, + doc={"arr": [1, 2, 3]}, + error_code=FILTER_LIMIT_NOT_POSITIVE_ERROR, + msg="$filter negative int should error", + ), + ExpressionTestCase( + "neg_long_limit", + expression={"$filter": {"input": "$arr", "cond": True, "limit": Int64(-1)}}, + doc={"arr": [1, 2, 3]}, + error_code=FILTER_LIMIT_NOT_POSITIVE_ERROR, + msg="$filter negative long should error", + ), + ExpressionTestCase( + "neg_double_limit", + expression={"$filter": {"input": "$arr", "cond": True, "limit": -1.0}}, + doc={"arr": [1, 2, 3]}, + error_code=FILTER_LIMIT_NOT_POSITIVE_ERROR, + msg="$filter negative double should error", + ), + ExpressionTestCase( + "neg_decimal128_limit", + expression={"$filter": {"input": "$arr", "cond": True, "limit": Decimal128("-1")}}, + doc={"arr": [1, 2, 3]}, + error_code=FILTER_LIMIT_NOT_POSITIVE_ERROR, + msg="$filter negative decimal128 should error", + ), + ExpressionTestCase( + "fractional_1_5_limit", + expression={"$filter": {"input": "$arr", "cond": True, "limit": 1.5}}, + doc={"arr": [1, 2, 3]}, + error_code=FILTER_LIMIT_NOT_INTEGRAL_ERROR, + msg="$filter fractional 1.5 should error", + ), + ExpressionTestCase( + "fractional_dec_0_5_limit", + expression={"$filter": {"input": "$arr", "cond": True, "limit": DECIMAL128_HALF}}, + doc={"arr": [1, 2, 3]}, + error_code=FILTER_LIMIT_NOT_INTEGRAL_ERROR, + msg="$filter fractional decimal128 0.5 should error", + ), + ExpressionTestCase( + "nan_limit", + expression={"$filter": {"input": "$arr", "cond": True, "limit": FLOAT_NAN}}, + doc={"arr": [1, 2, 3]}, + error_code=FILTER_LIMIT_NOT_INTEGRAL_ERROR, + msg="$filter naN should error", + ), + ExpressionTestCase( + "inf_limit", + expression={"$filter": {"input": "$arr", "cond": True, "limit": FLOAT_INFINITY}}, + doc={"arr": [1, 2, 3]}, + error_code=FILTER_LIMIT_NOT_INTEGRAL_ERROR, + msg="$filter infinity should error", + ), + ExpressionTestCase( + "neg_inf_limit", + expression={"$filter": {"input": "$arr", "cond": True, "limit": FLOAT_NEGATIVE_INFINITY}}, + doc={"arr": [1, 2, 3]}, + error_code=FILTER_LIMIT_NOT_INTEGRAL_ERROR, + msg="$filter -Infinity should error", + ), + ExpressionTestCase( + "decimal128_nan_limit", + expression={"$filter": {"input": "$arr", "cond": True, "limit": DECIMAL128_NAN}}, + doc={"arr": [1, 2, 3]}, + error_code=FILTER_LIMIT_NOT_INTEGRAL_ERROR, + msg="$filter decimal128 NaN should error", + ), + ExpressionTestCase( + "decimal128_inf_limit", + expression={"$filter": {"input": "$arr", "cond": True, "limit": DECIMAL128_INFINITY}}, + doc={"arr": [1, 2, 3]}, + error_code=FILTER_LIMIT_NOT_INTEGRAL_ERROR, + msg="$filter decimal128 Infinity should error", + ), + ExpressionTestCase( + "neg_zero_double_limit", + expression={"$filter": {"input": "$arr", "cond": True, "limit": DOUBLE_NEGATIVE_ZERO}}, + doc={"arr": [1, 2, 3]}, + error_code=FILTER_LIMIT_NOT_POSITIVE_ERROR, + msg="$filter -0.0 limit should error", + ), + ExpressionTestCase( + "neg_zero_decimal128_limit", + expression={"$filter": {"input": "$arr", "cond": True, "limit": DECIMAL128_NEGATIVE_ZERO}}, + doc={"arr": [1, 2, 3]}, + error_code=FILTER_LIMIT_NOT_POSITIVE_ERROR, + msg="$filter decimal128 -0 limit should error", + ), +] + +# Error: cond evaluation errors +COND_EVALUATION_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "cond_divide_by_zero", + expression={"$filter": {"input": "$arr", "cond": {"$divide": [1, 0]}}}, + doc={"arr": [1, 2]}, + error_code=BAD_VALUE_ERROR, + msg="$filter division by zero in cond should error", + ), +] + +# Aggregate and test +ALL_TESTS = ( + NOT_ARRAY_ERROR_TESTS + + SPECIAL_NUMERIC_ERROR_TESTS + + BOUNDARY_ERROR_TESTS + + INVALID_LIMIT_TYPE_TESTS + + INVALID_LIMIT_NUMERIC_TESTS + + COND_EVALUATION_ERROR_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_filter_error_insert(collection, test): + """Test $filter error with values from inserted documents.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/filter/test_filter_expressions.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/filter/test_filter_expressions.py new file mode 100644 index 000000000..207630821 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/filter/test_filter_expressions.py @@ -0,0 +1,170 @@ +""" +Expression and field path tests for $filter expression. + +Tests field path lookups, composite paths, system variables, +null/missing propagation via expressions, nested $filter, and +access to outer document fields in cond. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Field path lookups +FIELD_LOOKUP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_field_path", + expression={"$filter": {"input": "$a.b", "cond": {"$gt": ["$$this", 2]}}}, + doc={"a": {"b": [1, 2, 3, 4]}}, + expected=[3, 4], + msg="$filter should resolve nested field path", + ), + ExpressionTestCase( + "deeply_nested_field", + expression={"$filter": {"input": "$a.b.c", "cond": True}}, + doc={"a": {"b": {"c": [10, 20]}}}, + expected=[10, 20], + msg="$filter should resolve deeply nested field path", + ), + ExpressionTestCase( + "composite_array_path", + expression={"$filter": {"input": "$a.b", "cond": {"$gt": ["$$this", 1]}}}, + doc={"a": [{"b": 1}, {"b": 2}, {"b": 3}]}, + expected=[2, 3], + msg="$filter composite array path should resolve to array", + ), + ExpressionTestCase( + "index_path_on_object_key", + expression={"$filter": {"input": "$a.0.b", "cond": True}}, + doc={"a": {"0": {"b": [1, 2, 3]}}}, + expected=[1, 2, 3], + msg="$filter object key '0' resolves correctly", + ), + ExpressionTestCase( + "object_key_zero", + expression={"$filter": {"input": "$a.0", "cond": True}}, + doc={"a": {"0": [1, 2, 3]}}, + expected=[1, 2, 3], + msg="$a.0 resolves as field named '0' on object", + ), + ExpressionTestCase( + "access_outer_field", + expression={"$filter": {"input": "$arr", "cond": {"$gt": ["$$this", "$threshold"]}}}, + doc={"arr": [1, 2, 3, 4, 5], "threshold": 3}, + expected=[4, 5], + msg="$filter should access outer document field in cond", + ), +] + +# $let and system variables +LET_AND_VARIABLE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "let_variable", + expression={ + "$let": { + "vars": {"arr": "$values"}, + "in": {"$filter": {"input": "$$arr", "cond": {"$gt": ["$$this", 2]}}}, + } + }, + doc={"values": [1, 2, 3, 4]}, + expected=[3, 4], + msg="$filter should work with $let variables", + ), + ExpressionTestCase( + "root_variable", + expression={"$filter": {"input": "$$ROOT.values", "cond": {"$gt": ["$$this", 2]}}}, + doc={"_id": 1, "values": [1, 2, 3, 4]}, + expected=[3, 4], + msg="$filter should work with $$ROOT", + ), + ExpressionTestCase( + "current_variable", + expression={"$filter": {"input": "$$CURRENT.values", "cond": {"$gt": ["$$this", 2]}}}, + doc={"_id": 2, "values": [1, 2, 3, 4]}, + expected=[3, 4], + msg="$$CURRENT should be equivalent to field path", + ), +] + +# Null/missing via expression +NULL_MISSING_EXPR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "missing_field", + expression={"$filter": {"input": "$nonexistent", "cond": True}}, + doc={"other": 1}, + expected=None, + msg="$filter missing field should return null", + ), + ExpressionTestCase( + "remove_variable", + expression={"$filter": {"input": "$$REMOVE", "cond": True}}, + doc={"x": 1}, + expected=None, + msg="$$REMOVE propagates null", + ), +] + +# Nested $filter +NESTED_FILTER_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "filter_then_filter", + expression={ + "$filter": { + "input": {"$filter": {"input": "$arr", "cond": {"$gt": ["$$this", 1]}}}, + "cond": {"$lt": ["$$this", 5]}, + } + }, + doc={"arr": [1, 2, 3, 4, 5, 6]}, + expected=[2, 3, 4], + msg="$filter nested $filter should chain conditions", + ), +] + + +# Limit with field reference +LIMIT_EXPR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "limit_from_field", + expression={"$filter": {"input": "$arr", "cond": {"$gt": ["$$this", 0]}, "limit": "$n"}}, + doc={"arr": [1, 2, 3, 4, 5], "n": 2}, + expected=[1, 2], + msg="$filter limit from field reference", + ), +] + +# Literal array input (not field path) +LITERAL_INPUT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "literal_array_input", + expression={"$filter": {"input": [1, 2, 3, 4, 5], "cond": {"$gt": ["$$this", 3]}}}, + doc={"x": 1}, + expected=[4, 5], + msg="$filter should filter literal array input", + ), +] + +# Aggregate and test +ALL_EXPR_TESTS = ( + FIELD_LOOKUP_TESTS + + LET_AND_VARIABLE_TESTS + + NULL_MISSING_EXPR_TESTS + + NESTED_FILTER_TESTS + + LIMIT_EXPR_TESTS + + LITERAL_INPUT_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_EXPR_TESTS)) +def test_filter_expression(collection, test): + """Test $filter with field paths and expressions.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/filter/test_filter_structure_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/filter/test_filter_structure_errors.py new file mode 100644 index 000000000..8c680dd05 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/filter/test_filter_structure_errors.py @@ -0,0 +1,105 @@ +""" +Structural error tests for $filter expression. + +Tests invalid $filter argument structure: non-object argument, unknown/misspelled fields, +and missing required fields (input, cond). +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, +) +from documentdb_tests.framework.error_codes import ( + FILTER_MISSING_COND_ERROR, + FILTER_MISSING_INPUT_ERROR, + FILTER_NON_OBJECT_ARG_ERROR, + FILTER_UNKNOWN_FIELD_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Error: non-object argument +NON_OBJECT_ARG_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_arg", + expression={"$filter": None}, + error_code=FILTER_NON_OBJECT_ARG_ERROR, + msg="$filter null arg should error", + ), + ExpressionTestCase( + "int_arg", + expression={"$filter": 1}, + error_code=FILTER_NON_OBJECT_ARG_ERROR, + msg="$filter int arg should error", + ), + ExpressionTestCase( + "string_arg", + expression={"$filter": "string"}, + error_code=FILTER_NON_OBJECT_ARG_ERROR, + msg="$filter string arg should error", + ), + ExpressionTestCase( + "array_arg", + expression={"$filter": []}, + error_code=FILTER_NON_OBJECT_ARG_ERROR, + msg="$filter array arg should error", + ), + ExpressionTestCase( + "bool_arg", + expression={"$filter": True}, + error_code=FILTER_NON_OBJECT_ARG_ERROR, + msg="$filter bool arg should error", + ), +] + +# Error: unknown fields +UNKNOWN_FIELD_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "extra_unknown", + expression={"$filter": {"input": [1], "cond": True, "unknown": 1}}, + error_code=FILTER_UNKNOWN_FIELD_ERROR, + msg="$filter extra unknown field should error", + ), + ExpressionTestCase( + "only_unknown", + expression={"$filter": {"dummy": 124}}, + error_code=FILTER_UNKNOWN_FIELD_ERROR, + msg="$filter only unknown field should error", + ), +] + +# Error: missing required fields +MISSING_REQUIRED_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "missing_input", + expression={"$filter": {"as": "x", "cond": True}}, + error_code=FILTER_MISSING_INPUT_ERROR, + msg="$filter missing input should error", + ), + ExpressionTestCase( + "missing_cond", + expression={"$filter": {"input": [1, 2, 3]}}, + error_code=FILTER_MISSING_COND_ERROR, + msg="$filter missing cond should error", + ), + ExpressionTestCase( + "empty_object", + expression={"$filter": {}}, + error_code=FILTER_MISSING_INPUT_ERROR, + msg="$filter empty object should error", + ), +] + +# Aggregate and test +ALL_STRUCTURE_TESTS = NON_OBJECT_ARG_TESTS + UNKNOWN_FIELD_TESTS + MISSING_REQUIRED_TESTS + + +@pytest.mark.parametrize("test", pytest_params(ALL_STRUCTURE_TESTS)) +def test_filter_structure_error(collection, test): + """Test $filter argument structure validation.""" + result = execute_expression(collection, test.expression) + assert_expression_result(result, error_code=test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/filter/test_smoke_expression_filter.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/filter/test_smoke_filter.py similarity index 92% rename from documentdb_tests/compatibility/tests/core/operator/expressions/array/filter/test_smoke_expression_filter.py rename to documentdb_tests/compatibility/tests/core/operator/expressions/array/filter/test_smoke_filter.py index 49048c03b..828336022 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/array/filter/test_smoke_expression_filter.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/filter/test_smoke_filter.py @@ -40,4 +40,4 @@ def test_smoke_expression_filter(collection): ) expected = [{"_id": 1, "filtered": [4, 5]}, {"_id": 2, "filtered": [10, 15, 20, 25]}] - assertSuccess(result, expected, msg="Should support $filter expression") + assertSuccess(result, expected, ignore_doc_order=True, msg="Should support $filter expression") diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/in/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/in/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/in/test_in_bson_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/in/test_in_bson_types.py new file mode 100644 index 000000000..4d852a11e --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/in/test_in_bson_types.py @@ -0,0 +1,285 @@ +""" +BSON type and numeric equivalence tests for $in expression. + +Tests searching for various BSON types and cross-type numeric matching. +""" + +from datetime import datetime, timezone +from uuid import UUID + +import pytest +from bson import Binary, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ONE_AND_HALF, + DOUBLE_NEGATIVE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, +) + +# Success: search for various BSON types +BSON_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "bson_int64", + doc={"val": Int64(99), "arr": [Int64(99), 1]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find Int64 in array", + ), + ExpressionTestCase( + "bson_decimal128", + doc={"val": DECIMAL128_ONE_AND_HALF, "arr": [DECIMAL128_ONE_AND_HALF, 2]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find Decimal128 in array", + ), + ExpressionTestCase( + "bson_datetime", + doc={ + "val": datetime(2024, 1, 1, tzinfo=timezone.utc), + "arr": [datetime(2024, 1, 1, tzinfo=timezone.utc), 1], + }, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find datetime in array", + ), + ExpressionTestCase( + "bson_objectid", + doc={ + "val": ObjectId("000000000000000000000001"), + "arr": [ObjectId("000000000000000000000001"), 1], + }, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find ObjectId in array", + ), + ExpressionTestCase( + "bson_binary", + doc={"val": Binary(b"\x01\x02", 0), "arr": [Binary(b"\x01\x02", 0), 1]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find Binary in array", + ), + ExpressionTestCase( + "bson_regex", + doc={"val": Regex("^abc", "i"), "arr": [Regex("^abc", "i"), 1]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find Regex in array", + ), + ExpressionTestCase( + "bson_timestamp", + doc={"val": Timestamp(1, 1), "arr": [Timestamp(1, 1), 1]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find Timestamp in array", + ), + ExpressionTestCase( + "bson_minkey", + doc={"val": MinKey(), "arr": [MinKey(), 1]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find MinKey in array", + ), + ExpressionTestCase( + "bson_maxkey", + doc={"val": MaxKey(), "arr": [1, MaxKey()]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find MaxKey in array", + ), + ExpressionTestCase( + "bson_uuid", + doc={ + "val": Binary.from_uuid(UUID("01234567-89ab-cdef-fedc-ba9876543210")), + "arr": [Binary.from_uuid(UUID("01234567-89ab-cdef-fedc-ba9876543210")), 1], + }, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find UUID binary in array", + ), + # Special float values + ExpressionTestCase( + "float_infinity_in_array", + doc={"val": FLOAT_INFINITY, "arr": [FLOAT_INFINITY, 1]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find Infinity in array", + ), + ExpressionTestCase( + "float_neg_infinity_in_array", + doc={"val": FLOAT_NEGATIVE_INFINITY, "arr": [FLOAT_NEGATIVE_INFINITY, 1]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find -Infinity in array", + ), + ExpressionTestCase( + "float_infinity_not_in_array", + doc={"val": FLOAT_INFINITY, "arr": [1, 2, 3]}, + expression={"$in": ["$val", "$arr"]}, + expected=False, + msg="$in should not find Infinity in non-Infinity array", + ), + # Special Decimal128 values + ExpressionTestCase( + "decimal128_infinity_in_array", + doc={"val": DECIMAL128_INFINITY, "arr": [DECIMAL128_INFINITY, 1]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find Decimal128 Infinity in array", + ), + ExpressionTestCase( + "decimal128_neg_infinity_in_array", + doc={"val": DECIMAL128_NEGATIVE_INFINITY, "arr": [DECIMAL128_NEGATIVE_INFINITY, 1]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find Decimal128 -Infinity in array", + ), + # NaN equality: NaN == NaN in BSON comparison (unlike IEEE 754) + ExpressionTestCase( + "float_nan_found", + doc={"val": FLOAT_NAN, "arr": [FLOAT_NAN, 1]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find NaN in array (BSON equality)", + ), + ExpressionTestCase( + "decimal128_nan_found", + doc={"val": DECIMAL128_NAN, "arr": [DECIMAL128_NAN, 1]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find Decimal128 NaN in array (BSON equality)", + ), + ExpressionTestCase( + "float_nan_matches_decimal128_nan", + doc={"val": FLOAT_NAN, "arr": [DECIMAL128_NAN, 1]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in float NaN should match Decimal128 NaN cross-type", + ), + ExpressionTestCase( + "decimal128_nan_matches_float_nan", + doc={"val": DECIMAL128_NAN, "arr": [FLOAT_NAN, 1]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in decimal128 NaN should match float NaN cross-type", + ), + # Aggregation $in does NOT pattern-match regex against strings (unlike query $in) + ExpressionTestCase( + "regex_no_pattern_match", + doc={"val": Regex("^a"), "arr": ["abc", "def"]}, + expression={"$in": ["$val", "$arr"]}, + expected=False, + msg="$in regex should not pattern-match strings in aggregation $in", + ), +] + +# Success: numeric type equivalence +NUMERIC_EQUIVALENCE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int_in_doubles", + doc={"val": 1, "arr": [1.0, 2.0]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find int in doubles via numeric equivalence", + ), + ExpressionTestCase( + "int_in_longs", + doc={"val": 1, "arr": [Int64(1), 2]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find int in longs via numeric equivalence", + ), + ExpressionTestCase( + "int_in_decimal128s", + doc={"val": 1, "arr": [Decimal128("1"), 2]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find int in decimal128s via numeric equivalence", + ), + ExpressionTestCase( + "double_in_ints", + doc={"val": 1.0, "arr": [1, 2]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find double in ints via numeric equivalence", + ), + ExpressionTestCase( + "long_in_ints", + doc={"val": Int64(1), "arr": [1, 2]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find long in ints via numeric equivalence", + ), + ExpressionTestCase( + "decimal128_in_ints", + doc={"val": Decimal128("1"), "arr": [1, 2]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find decimal128 in ints via numeric equivalence", + ), + ExpressionTestCase( + "decimal128_in_doubles", + doc={"val": DECIMAL128_ONE_AND_HALF, "arr": [1.5, 2.5]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find decimal128 in doubles via numeric equivalence", + ), +] + +# Negative zero equivalence with positive zero +NEGATIVE_ZERO_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "double_neg_zero_matches_zero", + doc={"val": DOUBLE_NEGATIVE_ZERO, "arr": [0, 1, 2]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should treat -0.0 as equivalent to 0", + ), + ExpressionTestCase( + "zero_matches_double_neg_zero_in_array", + doc={"val": 0, "arr": [DOUBLE_NEGATIVE_ZERO, 1, 2]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find 0 in array containing -0.0", + ), + ExpressionTestCase( + "decimal128_neg_zero_matches_zero", + doc={"val": DECIMAL128_NEGATIVE_ZERO, "arr": [0, 1, 2]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should treat Decimal128 -0 as equivalent to 0", + ), + ExpressionTestCase( + "zero_matches_decimal128_neg_zero_in_array", + doc={"val": 0, "arr": [DECIMAL128_NEGATIVE_ZERO, 1, 2]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find 0 in array containing Decimal128 -0", + ), +] + +# Aggregate and test +ALL_TESTS = BSON_TYPE_TESTS + NUMERIC_EQUIVALENCE_TESTS + NEGATIVE_ZERO_TESTS + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_in_bson_insert(collection, test): + """Test $in BSON type matching with values from inserted documents.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/in/test_in_core_behavior.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/in/test_in_core_behavior.py new file mode 100644 index 000000000..f49c12f60 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/in/test_in_core_behavior.py @@ -0,0 +1,265 @@ +""" +Core behavior tests for $in expression. + +Tests value found/not found, mixed types, and large arrays. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Success: value found in array → True +FOUND_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "found_int", + doc={"val": 2, "arr": [1, 2, 3]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find int in array", + ), + ExpressionTestCase( + "found_first", + doc={"val": 1, "arr": [1, 2, 3]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find first element", + ), + ExpressionTestCase( + "found_last", + doc={"val": 3, "arr": [1, 2, 3]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find last element", + ), + ExpressionTestCase( + "found_string", + doc={"val": "b", "arr": ["a", "b", "c"]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find string in array", + ), + ExpressionTestCase( + "found_bool_true", + doc={"val": True, "arr": [True, False]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find true in array", + ), + ExpressionTestCase( + "found_bool_false", + doc={"val": False, "arr": [True, False]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find false in array", + ), + ExpressionTestCase( + "found_null", + doc={"val": None, "arr": [None, 1, 2]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find null in array", + ), + ExpressionTestCase( + "found_nested_array", + doc={"val": [3, 4], "arr": [[1, 2], [3, 4]]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find nested array", + ), + ExpressionTestCase( + "found_object", + doc={"val": {"a": 1}, "arr": [{"a": 1}, {"b": 2}]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find object in array", + ), + ExpressionTestCase( + "found_single_element", + doc={"val": 42, "arr": [42]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find value in single-element array", + ), + ExpressionTestCase( + "found_duplicate", + doc={"val": 5, "arr": [5, 5, 5]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find value in array of duplicates", + ), +] + +# Success: value not found → False +NOT_FOUND_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "not_found_int", + doc={"val": 4, "arr": [1, 2, 3]}, + expression={"$in": ["$val", "$arr"]}, + expected=False, + msg="$in should not find absent int", + ), + ExpressionTestCase( + "not_found_string", + doc={"val": "z", "arr": ["a", "b"]}, + expression={"$in": ["$val", "$arr"]}, + expected=False, + msg="$in should not find absent string", + ), + ExpressionTestCase( + "not_found_empty_array", + doc={"val": 1, "arr": []}, + expression={"$in": ["$val", "$arr"]}, + expected=False, + msg="$in should not find value in empty array", + ), + ExpressionTestCase( + "not_found_type_mismatch", + doc={"val": "1", "arr": [1, 2, 3]}, + expression={"$in": ["$val", "$arr"]}, + expected=False, + msg="$in should not find string '1' in int array", + ), + ExpressionTestCase( + "not_found_bool_vs_int", + doc={"val": True, "arr": [1, 0]}, + expression={"$in": ["$val", "$arr"]}, + expected=False, + msg="$in should not find bool in int array", + ), + ExpressionTestCase( + "not_found_null", + doc={"val": None, "arr": [1, 2, 3]}, + expression={"$in": ["$val", "$arr"]}, + expected=False, + msg="$in should not find null in non-null array", + ), + ExpressionTestCase( + "not_found_partial_array", + doc={"val": [1], "arr": [[1, 2], [3, 4]]}, + expression={"$in": ["$val", "$arr"]}, + expected=False, + msg="$in should not find partial array match", + ), + ExpressionTestCase( + "not_found_partial_object", + doc={"val": {"a": 1}, "arr": [{"a": 1, "b": 2}]}, + expression={"$in": ["$val", "$arr"]}, + expected=False, + msg="$in should not find partial object match", + ), +] + +# Success: mixed types in array +MIXED_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "mixed_find_string", + doc={"val": "2", "arr": [1, "2", True, None, [1]]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find string in mixed-type array", + ), + ExpressionTestCase( + "mixed_find_null", + doc={"val": None, "arr": [1, "2", True, None, [1]]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find null in mixed-type array", + ), + ExpressionTestCase( + "mixed_find_array", + doc={"val": [1], "arr": [1, "2", True, None, [1]]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find array in mixed-type array", + ), + ExpressionTestCase( + "mixed_not_found", + doc={"val": "x", "arr": [1, "2", True, None, [1]]}, + expression={"$in": ["$val", "$arr"]}, + expected=False, + msg="$in should not find absent value in mixed-type array", + ), +] + +# Success: large array + +LARGE_ARRAY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "large_array_found_first", + doc={"val": 0, "arr": list(range(20_000))}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find first element in large array", + ), + ExpressionTestCase( + "large_array_found_last", + doc={"val": 19_999, "arr": list(range(20_000))}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find last element in large array", + ), + ExpressionTestCase( + "large_array_found_middle", + doc={"val": 10_000, "arr": list(range(20_000))}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find middle element in large array", + ), + ExpressionTestCase( + "large_array_not_found", + doc={"val": -1, "arr": list(range(20_000))}, + expression={"$in": ["$val", "$arr"]}, + expected=False, + msg="$in should not find absent value in large array", + ), +] + +# Aggregate and test +# Property [Literal Evaluation]: $in evaluates correctly with inline literal values. +TEST_SUBSET_FOR_LITERAL: list[ExpressionTestCase] = [ + ExpressionTestCase( + "literal_found_int", + doc=None, + expression={"$in": [2, {"$literal": [1, 2, 3]}]}, + expected=True, + msg="$in should find int in literal array", + ), + ExpressionTestCase( + "literal_not_found_int", + doc=None, + expression={"$in": [4, {"$literal": [1, 2, 3]}]}, + expected=False, + msg="$in should not find absent int in literal array", + ), + ExpressionTestCase( + "literal_large_array_found", + doc=None, + expression={"$in": [0, {"$literal": list(range(20_000))}]}, + expected=True, + msg="$in should find value in large literal array", + ), +] + +ALL_TESTS = ( + FOUND_TESTS + NOT_FOUND_TESTS + MIXED_TYPE_TESTS + LARGE_ARRAY_TESTS + TEST_SUBSET_FOR_LITERAL +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_in_insert(collection, test): + """Test $in with values from inserted documents.""" + if test.doc is None: + result = execute_expression(collection, test.expression) + else: + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/in/test_in_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/in/test_in_errors.py new file mode 100644 index 000000000..279549c19 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/in/test_in_errors.py @@ -0,0 +1,188 @@ +""" +Error tests for $in expression. + +Tests non-array second argument and wrong arity errors. +""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + EXPRESSION_IN_NOT_ARRAY_ERROR, + EXPRESSION_TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import MISSING + +# Property [Not Array]: $in rejects non-array second argument across all BSON types. +NOT_ARRAY_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "string_as_array", + doc={"val": 1, "arr": "hello"}, + expression={"$in": ["$val", "$arr"]}, + error_code=EXPRESSION_IN_NOT_ARRAY_ERROR, + msg="$in should reject string as array arg", + ), + ExpressionTestCase( + "int_as_array", + doc={"val": 1, "arr": 42}, + expression={"$in": ["$val", "$arr"]}, + error_code=EXPRESSION_IN_NOT_ARRAY_ERROR, + msg="$in should reject int as array arg", + ), + ExpressionTestCase( + "double_as_array", + doc={"val": 1, "arr": 3.14}, + expression={"$in": ["$val", "$arr"]}, + error_code=EXPRESSION_IN_NOT_ARRAY_ERROR, + msg="$in should reject double as array arg", + ), + ExpressionTestCase( + "bool_true_as_array", + doc={"val": 1, "arr": True}, + expression={"$in": ["$val", "$arr"]}, + error_code=EXPRESSION_IN_NOT_ARRAY_ERROR, + msg="$in should reject bool true as array arg", + ), + ExpressionTestCase( + "bool_false_as_array", + doc={"val": 1, "arr": False}, + expression={"$in": ["$val", "$arr"]}, + error_code=EXPRESSION_IN_NOT_ARRAY_ERROR, + msg="$in should reject bool false as array arg", + ), + ExpressionTestCase( + "object_as_array", + doc={"val": 1, "arr": {"a": 1}}, + expression={"$in": ["$val", "$arr"]}, + error_code=EXPRESSION_IN_NOT_ARRAY_ERROR, + msg="$in should reject object as array arg", + ), + ExpressionTestCase( + "decimal128_as_array", + doc={"val": 1, "arr": Decimal128("1")}, + expression={"$in": ["$val", "$arr"]}, + error_code=EXPRESSION_IN_NOT_ARRAY_ERROR, + msg="$in should reject decimal128 as array arg", + ), + ExpressionTestCase( + "int64_as_array", + doc={"val": 1, "arr": Int64(1)}, + expression={"$in": ["$val", "$arr"]}, + error_code=EXPRESSION_IN_NOT_ARRAY_ERROR, + msg="$in should reject int64 as array arg", + ), + ExpressionTestCase( + "binary_as_array", + doc={"val": 1, "arr": Binary(b"x", 0)}, + expression={"$in": ["$val", "$arr"]}, + error_code=EXPRESSION_IN_NOT_ARRAY_ERROR, + msg="$in should reject binary as array arg", + ), + ExpressionTestCase( + "datetime_as_array", + doc={"val": 1, "arr": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + expression={"$in": ["$val", "$arr"]}, + error_code=EXPRESSION_IN_NOT_ARRAY_ERROR, + msg="$in should reject datetime as array arg", + ), + ExpressionTestCase( + "objectid_as_array", + doc={"val": 1, "arr": ObjectId()}, + expression={"$in": ["$val", "$arr"]}, + error_code=EXPRESSION_IN_NOT_ARRAY_ERROR, + msg="$in should reject objectid as array arg", + ), + ExpressionTestCase( + "regex_as_array", + doc={"val": 1, "arr": Regex("x")}, + expression={"$in": ["$val", "$arr"]}, + error_code=EXPRESSION_IN_NOT_ARRAY_ERROR, + msg="$in should reject regex as array arg", + ), + ExpressionTestCase( + "maxkey_as_array", + doc={"val": 1, "arr": MaxKey()}, + expression={"$in": ["$val", "$arr"]}, + error_code=EXPRESSION_IN_NOT_ARRAY_ERROR, + msg="$in should reject maxkey as array arg", + ), + ExpressionTestCase( + "minkey_as_array", + doc={"val": 1, "arr": MinKey()}, + expression={"$in": ["$val", "$arr"]}, + error_code=EXPRESSION_IN_NOT_ARRAY_ERROR, + msg="$in should reject minkey as array arg", + ), + ExpressionTestCase( + "timestamp_as_array", + doc={"val": 1, "arr": Timestamp(0, 0)}, + expression={"$in": ["$val", "$arr"]}, + error_code=EXPRESSION_IN_NOT_ARRAY_ERROR, + msg="$in should reject timestamp as array arg", + ), + ExpressionTestCase( + "null_as_array", + doc={"val": 1, "arr": None}, + expression={"$in": ["$val", "$arr"]}, + error_code=EXPRESSION_IN_NOT_ARRAY_ERROR, + msg="$in should reject null as array arg", + ), +] + +# Property [Missing Field]: $in rejects a missing field as the second argument. +LITERAL_ONLY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "missing_as_array", + doc={"val": 1, "arr": MISSING}, + expression={"$in": ["$val", "$arr"]}, + error_code=EXPRESSION_IN_NOT_ARRAY_ERROR, + msg="$in should reject missing as array arg", + ), +] + +# Property [Arity]: $in requires exactly two arguments. +ARITY_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "zero_args", + expression={"$in": []}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="$in should reject zero arguments", + ), + ExpressionTestCase( + "one_arg", + expression={"$in": [[1, 2, 3]]}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="$in should reject one argument", + ), + ExpressionTestCase( + "three_args", + expression={"$in": [1, [1, 2], 3]}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="$in should reject three arguments", + ), +] + +ALL_ERROR_TESTS = NOT_ARRAY_ERROR_TESTS + LITERAL_ONLY_TESTS + ARITY_ERROR_TESTS + + +@pytest.mark.parametrize("test", pytest_params(ALL_ERROR_TESTS)) +def test_in_error(collection, test): + """Test $in error cases.""" + if test.doc is None: + result = execute_expression(collection, test.expression) + else: + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/in/test_in_expressions.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/in/test_in_expressions.py new file mode 100644 index 000000000..bd6e4efe9 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/in/test_in_expressions.py @@ -0,0 +1,113 @@ +""" +Expression and field path tests for $in expression. + +Tests nested expressions, field path lookups, composite paths, +and non-existent field handling. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import EXPRESSION_IN_NOT_ARRAY_ERROR +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Nested Expressions]: $in evaluates nested expressions as arguments. +NESTED_EXPRESSION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_in_in", + expression={"$in": [{"$in": [2, [1, 2, 3]]}, [True, False]]}, + expected=True, + msg="$in should accept nested $in result as search value", + ), +] + +# Property [Field Path Resolution]: $in resolves nested and deeply nested field paths. +FIELD_LOOKUP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_field_found", + expression={"$in": [20, "$a.b"]}, + doc={"a": {"b": [10, 20, 30]}}, + expected=True, + msg="$in should find value in nested field path array", + ), + ExpressionTestCase( + "nested_field_not_found", + expression={"$in": [99, "$a.b"]}, + doc={"a": {"b": [10, 20, 30]}}, + expected=False, + msg="$in should not find absent value in nested field path array", + ), + ExpressionTestCase( + "deeply_nested_field", + expression={"$in": [7, "$a.b.c"]}, + doc={"a": {"b": {"c": [5, 6, 7]}}}, + expected=True, + msg="$in should resolve deeply nested field path", + ), +] + +# Property [Missing Field]: $in handles missing array and value fields correctly. +MISSING_FIELD_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nonexistent_array_field", + expression={"$in": [1, "$nonexistent"]}, + doc={"other": 1}, + error_code=EXPRESSION_IN_NOT_ARRAY_ERROR, + msg="$in should error when array field does not exist", + ), + ExpressionTestCase( + "nonexistent_value_array_contains_null", + expression={"$in": ["$nonexistent", "$arr"]}, + doc={"arr": [1, None, 3]}, + expected=False, + msg="$in should return false for missing value even when array contains null", + ), + ExpressionTestCase( + "nonexistent_value_array_without_null", + expression={"$in": ["$nonexistent", "$arr"]}, + doc={"arr": [1, 2, 3]}, + expected=False, + msg="$in should return false for missing value in array without null", + ), +] + +# Property [Composite Paths]: $in resolves composite array paths from array-of-objects. +COMPOSITE_PATH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "composite_array_as_array", + expression={"$in": [20, "$x.y"]}, + doc={"x": [{"y": 10}, {"y": 20}, {"y": 30}]}, + expected=True, + msg="$in should find value in composite array path", + ), + ExpressionTestCase( + "composite_array_as_value", + expression={"$in": ["$x.y", [[10, 20, 30], "other"]]}, + doc={"x": [{"y": 10}, {"y": 20}, {"y": 30}]}, + expected=True, + msg="$in should match composite array as search value", + ), +] + +ALL_EXPRESSION_TESTS = ( + NESTED_EXPRESSION_TESTS + FIELD_LOOKUP_TESTS + MISSING_FIELD_TESTS + COMPOSITE_PATH_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_EXPRESSION_TESTS)) +def test_in_expression(collection, test): + """Test $in with expressions, field paths, and composite paths.""" + if test.doc is None: + result = execute_expression(collection, test.expression) + else: + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/in/test_in_nested_arrays.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/in/test_in_nested_arrays.py new file mode 100644 index 000000000..16e9946a5 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/in/test_in_nested_arrays.py @@ -0,0 +1,199 @@ +""" +Nested array search tests for $in expression. + +Tests searching for complex elements in nested mixed arrays and deeply nested structures. +""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Decimal128, MaxKey, MinKey, ObjectId + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import DECIMAL128_ONE_AND_HALF + +# Success: nested mixed arrays as search targets +NESTED_MIXED_ARRAY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_find_object_in_mixed", + doc={"val": {"a": 1}, "arr": [1, "two", {"a": 1}, [3, 4], True]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find object in nested mixed array", + ), + ExpressionTestCase( + "nested_find_array_in_mixed", + doc={"val": [3, 4], "arr": [1, "two", {"a": 1}, [3, 4], True]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find array in nested mixed array", + ), + ExpressionTestCase( + "nested_find_deep_object", + doc={"val": {"a": {"b": 3}}, "arr": [[1, 2], {"a": {"b": 3}}, "x"]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find deep object in array", + ), + ExpressionTestCase( + "nested_find_array_with_mixed_types", + doc={"val": [None, "a", 2], "arr": [1, [None, "a", 2], "b"]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find mixed-type subarray", + ), + ExpressionTestCase( + "nested_find_empty_object", + doc={"val": {}, "arr": [1, {}, [2], "a"]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find empty object in array", + ), + ExpressionTestCase( + "nested_find_empty_array", + doc={"val": [], "arr": [1, {}, [], "a"]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find empty array in array", + ), + ExpressionTestCase( + "nested_find_subarray_binary_decimal128", + doc={ + "val": [Binary(b"\x01\x02", 0), Decimal128("3.14")], + "arr": [1, [Binary(b"\x01\x02", 0), Decimal128("3.14")], "x", [3]], + }, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find subarray with binary and decimal128", + ), + ExpressionTestCase( + "nested_find_subarray_object_array", + doc={"val": [{"k": 1}, [2, 3]], "arr": ["a", [{"k": 1}, [2, 3]], None, 4]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find subarray with object and array", + ), + ExpressionTestCase( + "nested_find_subarray_datetime_objectid", + doc={ + "val": [ + datetime(2024, 1, 1, tzinfo=timezone.utc), + ObjectId("000000000000000000000001"), + ], + "arr": [ + 0, + [datetime(2024, 1, 1, tzinfo=timezone.utc), ObjectId("000000000000000000000001")], + "end", + ], + }, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find subarray with datetime and objectid", + ), + ExpressionTestCase( + "nested_find_subarray_minkey_maxkey", + doc={"val": [MinKey(), MaxKey()], "arr": [[MinKey(), MaxKey()], 1, "a"]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find subarray with minkey and maxkey", + ), +] + +# Success: deeply nested search targets (3-5 levels) +DEEPLY_NESTED_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_3_levels", + doc={"val": [[2, 3], [4, 5]], "arr": [1, [[2, 3], [4, 5]], "end"]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find 3-level nested array", + ), + ExpressionTestCase( + "nested_4_levels", + doc={"val": [[[1, 2], 3], 4], "arr": ["a", [[[1, 2], 3], 4], None]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find 4-level nested array", + ), + ExpressionTestCase( + "nested_deep_mixed_bson", + doc={ + "val": [[MinKey(), {"a": [DECIMAL128_ONE_AND_HALF]}], True], + "arr": [0, [[MinKey(), {"a": [DECIMAL128_ONE_AND_HALF]}], True], "x"], + }, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find deeply nested mixed BSON", + ), + ExpressionTestCase( + "nested_inner_not_outer", + doc={"val": [2, 3], "arr": [[1, [2, 3]], [2, 3], 4]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find inner array match", + ), + ExpressionTestCase( + "nested_5_levels", + doc={"val": [[[[99]]]], "arr": [[[[[99]]]], "other"]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find 5-level nested array", + ), + ExpressionTestCase( + "nested_deep_not_found", + doc={"val": [2, 3], "arr": [[1, [2, 3]], [4, 5]]}, + expression={"$in": ["$val", "$arr"]}, + expected=False, + msg="$in should not find array at wrong nesting level", + ), +] + +# Aggregate and test +# Property [Literal Evaluation]: $in nested array matching works with inline literal values. +TEST_SUBSET_FOR_LITERAL: list[ExpressionTestCase] = [ + ExpressionTestCase( + "literal_nested_find_object_in_mixed", + doc=None, + expression={"$in": [{"a": 1}, {"$literal": [1, "two", {"a": 1}, [3, 4], True]}]}, + expected=True, + msg="$in should find object in literal nested mixed array", + ), + ExpressionTestCase( + "literal_nested_3_levels", + doc=None, + expression={ + "$in": [{"$literal": [[2, 3], [4, 5]]}, {"$literal": [1, [[2, 3], [4, 5]], "end"]}] + }, + expected=True, + msg="$in should find 3-level nested array in literal", + ), + ExpressionTestCase( + "literal_nested_deep_not_found", + doc=None, + expression={"$in": [{"$literal": [2, 3]}, {"$literal": [[1, [2, 3]], [4, 5]]}]}, + expected=False, + msg="$in should not find array at wrong nesting level in literal", + ), +] + +ALL_TESTS = NESTED_MIXED_ARRAY_TESTS + DEEPLY_NESTED_TESTS + TEST_SUBSET_FOR_LITERAL + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_in_nested_insert(collection, test): + """Test $in nested array matching with values from inserted documents.""" + if test.doc is None: + result = execute_expression(collection, test.expression) + else: + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/in/test_in_null_missing.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/in/test_in_null_missing.py new file mode 100644 index 000000000..710449768 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/in/test_in_null_missing.py @@ -0,0 +1,66 @@ +""" +Null and missing field handling tests for $in expression. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import MISSING + +# Property [Null/Missing]: $in returns null when value or array is null or missing. +NULL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_value_in_array", + doc={"val": None, "arr": [1, None, 3]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find null value in array containing null", + ), + ExpressionTestCase( + "null_value_not_in_array", + doc={"val": None, "arr": [1, 2, 3]}, + expression={"$in": ["$val", "$arr"]}, + expected=False, + msg="$in should not find null in array without null", + ), +] + +# Property [Missing Value]: $in handles missing value field correctly. +LITERAL_ONLY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "missing_value", + doc={"val": MISSING, "arr": [1, 2, 3]}, + expression={"$in": ["$val", "$arr"]}, + expected=False, + msg="$in should not find missing value in array", + ), + ExpressionTestCase( + "missing_value_null_in_array", + doc={"val": MISSING, "arr": [1, None, 3]}, + expression={"$in": ["$val", "$arr"]}, + expected=False, + msg="$in should not find missing value even with null in array", + ), +] + +# Aggregate and test +TEST_SUBSET_FOR_LITERAL = [ + NULL_TESTS[0], # null_value_in_array + NULL_TESTS[1], # null_value_not_in_array +] + LITERAL_ONLY_TESTS + + +@pytest.mark.parametrize("test", pytest_params(NULL_TESTS)) +def test_in_insert(collection, test): + """Test $in null with values from inserted documents.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/in/test_smoke_expression_in.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/in/test_smoke_in.py similarity index 89% rename from documentdb_tests/compatibility/tests/core/operator/expressions/array/in/test_smoke_expression_in.py rename to documentdb_tests/compatibility/tests/core/operator/expressions/array/in/test_smoke_in.py index 46e97f208..4de6a75e8 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/array/in/test_smoke_expression_in.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/in/test_smoke_in.py @@ -28,4 +28,4 @@ def test_smoke_expression_in(collection): ) expected = [{"_id": 1, "found": True}, {"_id": 2, "found": False}] - assertSuccess(result, expected, msg="Should support $in expression") + assertSuccess(result, expected, ignore_doc_order=True, msg="Should support $in expression") diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/indexOfArray/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/indexOfArray/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/indexOfArray/test_indexOfArray_bson_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/indexOfArray/test_indexOfArray_bson_types.py new file mode 100644 index 000000000..431b06594 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/indexOfArray/test_indexOfArray_bson_types.py @@ -0,0 +1,464 @@ +""" +BSON type search and nested array tests for $indexOfArray expression. + +Tests searching for various BSON types, numeric equivalence, and +complex nested mixed arrays. +""" + +from datetime import datetime, timezone +from uuid import UUID + +import pytest +from bson import Binary, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_NAN, + DECIMAL128_ONE_AND_HALF, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, +) + +# Success: search for various BSON types +BSON_TYPE_SEARCH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "search_int64", + doc={"arr": [Int64(99), 1], "search": Int64(99)}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should find Int64 value", + ), + ExpressionTestCase( + "search_decimal128", + doc={"arr": [DECIMAL128_ONE_AND_HALF, 2], "search": DECIMAL128_ONE_AND_HALF}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should find Decimal128 value", + ), + ExpressionTestCase( + "search_datetime", + doc={ + "arr": [datetime(2024, 1, 1, tzinfo=timezone.utc), 1], + "search": datetime(2024, 1, 1, tzinfo=timezone.utc), + }, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should find datetime value", + ), + ExpressionTestCase( + "search_objectid", + doc={ + "arr": [ObjectId("000000000000000000000001"), 1], + "search": ObjectId("000000000000000000000001"), + }, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should find ObjectId value", + ), + ExpressionTestCase( + "search_binary", + doc={"arr": [Binary(b"\x01\x02", 0), 1], "search": Binary(b"\x01\x02", 0)}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should find Binary value", + ), + ExpressionTestCase( + "search_regex", + doc={"arr": [Regex("^abc", "i"), 1], "search": Regex("^abc", "i")}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should find Regex value", + ), + ExpressionTestCase( + "search_timestamp", + doc={"arr": [Timestamp(1, 1), 1], "search": Timestamp(1, 1)}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should find Timestamp value", + ), + ExpressionTestCase( + "search_minkey", + doc={"arr": [MinKey(), 1], "search": MinKey()}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should find MinKey value", + ), + ExpressionTestCase( + "search_maxkey", + doc={"arr": [1, MaxKey()], "search": MaxKey()}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=1, + msg="$indexOfArray should find MaxKey value", + ), + ExpressionTestCase( + "search_uuid", + doc={ + "arr": [Binary.from_uuid(UUID("01234567-89ab-cdef-fedc-ba9876543210")), 1], + "search": Binary.from_uuid(UUID("01234567-89ab-cdef-fedc-ba9876543210")), + }, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should find UUID binary value", + ), + # Special float values + ExpressionTestCase( + "search_infinity", + doc={"arr": [FLOAT_INFINITY, 1], "search": FLOAT_INFINITY}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should find Infinity", + ), + ExpressionTestCase( + "search_neg_infinity", + doc={"arr": [FLOAT_NEGATIVE_INFINITY, 1], "search": FLOAT_NEGATIVE_INFINITY}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should find -Infinity", + ), + ExpressionTestCase( + "search_infinity_not_found", + doc={"arr": [1, 2, 3], "search": FLOAT_INFINITY}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=-1, + msg="$indexOfArray should not find Infinity in regular array", + ), + # Special Decimal128 values + ExpressionTestCase( + "search_decimal128_infinity", + doc={"arr": [DECIMAL128_INFINITY, 1], "search": DECIMAL128_INFINITY}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should find Decimal128 Infinity", + ), + ExpressionTestCase( + "search_decimal128_neg_infinity", + doc={ + "arr": [DECIMAL128_NEGATIVE_INFINITY, 1], + "search": DECIMAL128_NEGATIVE_INFINITY, + }, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should find Decimal128 -Infinity", + ), + # NaN equality: NaN == NaN in BSON comparison (unlike IEEE 754) + ExpressionTestCase( + "search_nan_found", + doc={"arr": [FLOAT_NAN, 1], "search": FLOAT_NAN}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should find NaN (BSON equality)", + ), + ExpressionTestCase( + "search_decimal128_nan_found", + doc={"arr": [DECIMAL128_NAN, 1], "search": DECIMAL128_NAN}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should find Decimal128 NaN (BSON equality)", + ), + # Cross-type NaN matching + ExpressionTestCase( + "search_decimal128_nan_matches_float_nan", + doc={"arr": [FLOAT_NAN, 1], "search": DECIMAL128_NAN}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray decimal128 NaN should match float NaN cross-type", + ), + ExpressionTestCase( + "search_decimal128_neg_nan_matches_nan", + doc={"arr": [DECIMAL128_NAN, 1], "search": DECIMAL128_NEGATIVE_NAN}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray decimal128 -NaN should match Decimal128 NaN", + ), +] + +# Success: numeric type equivalence in search +NUMERIC_EQUIVALENCE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int_matches_double", + doc={"arr": [1.0, 2.0], "search": 1}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should match int to double", + ), + ExpressionTestCase( + "int_matches_long", + doc={"arr": [Int64(1), 2], "search": 1}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should match int to long", + ), + ExpressionTestCase( + "int_matches_decimal128", + doc={"arr": [Decimal128("1"), 2], "search": 1}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should match int to decimal128", + ), + ExpressionTestCase( + "double_matches_int", + doc={"arr": [1, 2], "search": 1.0}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should match double to int", + ), + ExpressionTestCase( + "long_matches_int", + doc={"arr": [1, 2], "search": Int64(1)}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should match long to int", + ), + ExpressionTestCase( + "decimal128_matches_int", + doc={"arr": [1, 2], "search": Decimal128("1")}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should match decimal128 to int", + ), +] + +# Success: nested mixed arrays +NESTED_MIXED_ARRAY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_find_object_in_mixed", + doc={"arr": [1, "two", {"a": 1}, [3, 4], True], "search": {"a": 1}}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=2, + msg="$indexOfArray should find object in mixed array", + ), + ExpressionTestCase( + "nested_find_array_in_mixed", + doc={"arr": [1, "two", {"a": 1}, [3, 4], True], "search": [3, 4]}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=3, + msg="$indexOfArray should find array in mixed array", + ), + ExpressionTestCase( + "nested_find_bool_in_mixed", + doc={"arr": [1, "two", {"a": 1}, [3, 4], True], "search": True}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=4, + msg="$indexOfArray should find bool in mixed array", + ), + ExpressionTestCase( + "nested_find_null_in_mixed", + doc={"arr": [1, None, "three", [4], {"b": 2}], "search": None}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=1, + msg="$indexOfArray should find null in mixed array", + ), + ExpressionTestCase( + "nested_find_deep_object", + doc={"arr": [[1, 2], {"a": {"b": 3}}, "x"], "search": {"a": {"b": 3}}}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=1, + msg="$indexOfArray should find deep object", + ), + ExpressionTestCase( + "nested_find_array_with_mixed_types", + doc={"arr": [1, [None, "a", 2], "b"], "search": [None, "a", 2]}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=1, + msg="$indexOfArray should find mixed-type subarray", + ), + ExpressionTestCase( + "nested_find_empty_object_in_mixed", + doc={"arr": [1, {}, [2], "a"], "search": {}}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=1, + msg="$indexOfArray should find empty object", + ), + ExpressionTestCase( + "nested_find_empty_array_in_mixed", + doc={"arr": [1, {}, [], "a"], "search": []}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=2, + msg="$indexOfArray should find empty array", + ), + ExpressionTestCase( + "nested_find_datetime_in_mixed", + doc={ + "arr": ["a", datetime(2024, 1, 1, tzinfo=timezone.utc), 3, [4]], + "search": datetime(2024, 1, 1, tzinfo=timezone.utc), + }, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=1, + msg="$indexOfArray should find datetime in mixed array", + ), + ExpressionTestCase( + "nested_find_objectid_in_mixed", + doc={ + "arr": [1, ObjectId("000000000000000000000001"), "x", [2]], + "search": ObjectId("000000000000000000000001"), + }, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=1, + msg="$indexOfArray should find objectid in mixed array", + ), + ExpressionTestCase( + "nested_find_decimal128_in_mixed", + doc={"arr": ["a", [1], Decimal128("3.14"), None], "search": Decimal128("3.14")}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=2, + msg="$indexOfArray should find decimal128 in mixed array", + ), + ExpressionTestCase( + "nested_find_binary_in_mixed", + doc={"arr": [1, Binary(b"\x01\x02", 0), "x", [3]], "search": Binary(b"\x01\x02", 0)}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=1, + msg="$indexOfArray should find binary in mixed array", + ), + ExpressionTestCase( + "nested_find_subarray_binary_decimal128", + doc={ + "arr": [1, [Binary(b"\x01\x02", 0), Decimal128("3.14")], "x", [3]], + "search": [Binary(b"\x01\x02", 0), Decimal128("3.14")], + }, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=1, + msg="$indexOfArray should find subarray with binary and decimal128", + ), + ExpressionTestCase( + "nested_find_subarray_object_array", + doc={"arr": ["a", [{"k": 1}, [2, 3]], None, 4], "search": [{"k": 1}, [2, 3]]}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=1, + msg="$indexOfArray should find subarray with object and array", + ), + ExpressionTestCase( + "nested_find_subarray_null_bool_int", + doc={"arr": [[None, True, 42], "x", 1], "search": [None, True, 42]}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should find subarray with null bool int", + ), + ExpressionTestCase( + "nested_find_subarray_datetime_objectid", + doc={ + "arr": [ + 0, + [ + datetime(2024, 1, 1, tzinfo=timezone.utc), + ObjectId("000000000000000000000001"), + ], + "end", + ], + "search": [ + datetime(2024, 1, 1, tzinfo=timezone.utc), + ObjectId("000000000000000000000001"), + ], + }, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=1, + msg="$indexOfArray should find subarray with datetime and objectid", + ), + ExpressionTestCase( + "nested_find_subarray_minkey_maxkey", + doc={"arr": [[MinKey(), MaxKey()], 1, "a"], "search": [MinKey(), MaxKey()]}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should find subarray with minkey and maxkey", + ), + ExpressionTestCase( + "nested_3_levels_deep", + doc={"arr": [1, [[2, 3], [4, 5]], "end"], "search": [[2, 3], [4, 5]]}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=1, + msg="$indexOfArray should find 3-level nested array", + ), + ExpressionTestCase( + "nested_4_levels_deep", + doc={"arr": ["a", [[[1, 2], 3], 4], None], "search": [[[1, 2], 3], 4]}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=1, + msg="$indexOfArray should find 4-level nested array", + ), + ExpressionTestCase( + "nested_deep_mixed_bson", + doc={ + "arr": [0, [[MinKey(), {"a": [DECIMAL128_ONE_AND_HALF]}], True], "x"], + "search": [[MinKey(), {"a": [DECIMAL128_ONE_AND_HALF]}], True], + }, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=1, + msg="$indexOfArray should find deeply nested mixed BSON", + ), + ExpressionTestCase( + "nested_inner_not_outer", + doc={"arr": [[1, [2, 3]], [2, 3], 4], "search": [2, 3]}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=1, + msg="$indexOfArray should find inner array match", + ), + ExpressionTestCase( + "nested_5_levels_deep", + doc={"arr": [[[[[99]]]], "other"], "search": [[[[99]]]]}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should find 5-level nested array", + ), + ExpressionTestCase( + "nested_deep_not_found", + doc={"arr": [[1, [2, 3]], [4, 5]], "search": [2, 3]}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=-1, + msg="$indexOfArray should not find array at wrong nesting level", + ), +] + +# Aggregate and test +ALL_TESTS = BSON_TYPE_SEARCH_TESTS + NUMERIC_EQUIVALENCE_TESTS + NESTED_MIXED_ARRAY_TESTS + +# Property [Literal Evaluation]: BSON type search with inline literal values. +TEST_SUBSET_FOR_LITERAL: list[ExpressionTestCase] = [ + ExpressionTestCase( + "search_int64", + expression={"$indexOfArray": [[Int64(99), 1], Int64(99)]}, + expected=0, + msg="$indexOfArray should find Int64 value", + ), + ExpressionTestCase( + "int_matches_double", + expression={"$indexOfArray": [[1.0, 2.0], 1]}, + expected=0, + msg="$indexOfArray should match int to double", + ), + ExpressionTestCase( + "nested_find_object_in_mixed", + expression={"$indexOfArray": [[1, "two", {"a": 1}, [3, 4], True], {"a": 1}]}, + expected=2, + msg="$indexOfArray should find object in mixed array", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(TEST_SUBSET_FOR_LITERAL)) +def test_indexOfArray_literal(collection, test): + """Test $indexOfArray BSON types with literal values.""" + result = execute_expression(collection, test.expression) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_indexOfArray_insert(collection, test): + """Test $indexOfArray BSON types with values from inserted documents.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/indexOfArray/test_indexOfArray_core_behavior.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/indexOfArray/test_indexOfArray_core_behavior.py new file mode 100644 index 000000000..e3cbdba6e --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/indexOfArray/test_indexOfArray_core_behavior.py @@ -0,0 +1,647 @@ +""" +Core behavior tests for $indexOfArray expression. + +Tests basic search, not found, start/end index ranges, degenerate cases, +mixed types, and large arrays. +""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ZERO, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_ZERO, + INT32_MAX, + INT64_ZERO, +) + +# Success: basic search — value found +BASIC_FOUND_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "found_first", + doc={"arr": [1, 2, 3], "search": 1}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should find first element at index 0", + ), + ExpressionTestCase( + "found_middle", + doc={"arr": [1, 2, 3], "search": 2}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=1, + msg="$indexOfArray should find middle element at index 1", + ), + ExpressionTestCase( + "found_last", + doc={"arr": [1, 2, 3], "search": 3}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=2, + msg="$indexOfArray should find last element at index 2", + ), + ExpressionTestCase( + "found_string", + doc={"arr": ["a", "b", "c"], "search": "b"}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=1, + msg="$indexOfArray should find string in array", + ), + ExpressionTestCase( + "found_bool_true", + doc={"arr": [True, False], "search": True}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should find true at index 0", + ), + ExpressionTestCase( + "found_bool_false", + doc={"arr": [True, False], "search": False}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=1, + msg="$indexOfArray should find false at index 1", + ), + ExpressionTestCase( + "found_null", + doc={"arr": [None, 1, 2], "search": None}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should find null at index 0", + ), + ExpressionTestCase( + "found_nested_array", + doc={"arr": [[1, 2], [3, 4]], "search": [3, 4]}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=1, + msg="$indexOfArray should find nested array", + ), + ExpressionTestCase( + "found_object", + doc={"arr": [{"a": 1}, {"b": 2}], "search": {"a": 1}}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should find object in array", + ), + ExpressionTestCase( + "found_single_element", + doc={"arr": [42], "search": 42}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should find value in single-element array", + ), + ExpressionTestCase( + "first_occurrence", + doc={"arr": [1, 2, 1, 2], "search": 1}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should return first occurrence index", + ), + ExpressionTestCase( + "duplicate_values", + doc={"arr": [5, 5, 5], "search": 5}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should return first index for duplicates", + ), +] + +# Success: value not found → -1 +NOT_FOUND_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "not_found_int", + doc={"arr": [1, 2, 3], "search": 4}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=-1, + msg="$indexOfArray should return -1 for absent int", + ), + ExpressionTestCase( + "not_found_string", + doc={"arr": ["a", "b"], "search": "z"}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=-1, + msg="$indexOfArray should return -1 for absent string", + ), + ExpressionTestCase( + "empty_array", + doc={"arr": [], "search": 1}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=-1, + msg="$indexOfArray should return -1 for empty array", + ), + ExpressionTestCase( + "type_mismatch_search", + doc={"arr": [1, 2, 3], "search": "1"}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=-1, + msg="$indexOfArray should return -1 for type mismatch", + ), + ExpressionTestCase( + "bool_vs_int", + doc={"arr": [1, 0], "search": True}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=-1, + msg="$indexOfArray should return -1 for bool vs int", + ), + ExpressionTestCase( + "not_found_null", + doc={"arr": [1, 2, 3], "search": None}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=-1, + msg="$indexOfArray should return -1 for null not in array", + ), + ExpressionTestCase( + "not_found_partial_array", + doc={"arr": [[1, 2], [3, 4]], "search": [1]}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=-1, + msg="$indexOfArray should return -1 for partial array match", + ), + ExpressionTestCase( + "not_found_partial_object", + doc={"arr": [{"a": 1, "b": 2}], "search": {"a": 1}}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=-1, + msg="$indexOfArray should return -1 for partial object match", + ), +] + +# Success: with start index +START_INDEX_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "start_skips_first", + doc={"arr": [1, 2, 1, 2], "search": 1, "start": 1}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + expected=2, + msg="$indexOfArray should skip first match with start index", + ), + ExpressionTestCase( + "start_at_match", + doc={"arr": [10, 20, 30], "search": 20, "start": 1}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + expected=1, + msg="$indexOfArray should find at start index", + ), + ExpressionTestCase( + "start_past_match", + doc={"arr": [10, 20, 30], "search": 10, "start": 1}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + expected=-1, + msg="$indexOfArray should return -1 when start is past match", + ), + ExpressionTestCase( + "start_at_zero", + doc={"arr": [1, 2, 3], "search": 1, "start": 0}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + expected=0, + msg="$indexOfArray should find with start at zero", + ), + ExpressionTestCase( + "start_beyond_array", + doc={"arr": [1, 2, 3], "search": 1, "start": 20}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + expected=-1, + msg="$indexOfArray should return -1 when start beyond array", + ), + ExpressionTestCase( + "start_int64", + doc={"arr": [10, 20, 30], "search": 20, "start": Int64(1)}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + expected=1, + msg="$indexOfArray should accept Int64 start index", + ), + ExpressionTestCase( + "start_double_integral", + doc={"arr": [10, 20, 30], "search": 30, "start": 2.0}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + expected=2, + msg="$indexOfArray should accept integral double start index", + ), + ExpressionTestCase( + "start_decimal128_integral", + doc={"arr": [10, 20, 30], "search": 20, "start": Decimal128("1")}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + expected=1, + msg="$indexOfArray should accept Decimal128 start index", + ), + ExpressionTestCase( + "start_decimal128_10E_neg1", + doc={"arr": [10, 20, 30], "search": 20, "start": Decimal128("10E-1")}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + expected=1, + msg="$indexOfArray should accept Decimal128 10E-1 as start index 1", + ), + ExpressionTestCase( + "end_decimal128_30E_neg1", + doc={"arr": [10, 20, 30], "search": 20, "start": 0, "end": Decimal128("30E-1")}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + expected=1, + msg="$indexOfArray should accept Decimal128 30E-1 as end index 3", + ), +] + +# Success: with start and end index +START_END_INDEX_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "range_found", + doc={"arr": ["a", "b", "c", "b"], "search": "b", "start": 1, "end": 3}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + expected=1, + msg="$indexOfArray should find in range", + ), + ExpressionTestCase( + "range_not_found_exclusive_end", + doc={"arr": ["a", "b", "c"], "search": "c", "start": 0, "end": 2}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + expected=-1, + msg="$indexOfArray should not find at exclusive end", + ), + ExpressionTestCase( + "range_end_equals_start", + doc={"arr": [1, 2, 3], "search": 1, "start": 0, "end": 0}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + expected=-1, + msg="$indexOfArray should return -1 when end equals start", + ), + ExpressionTestCase( + "range_end_less_than_start", + doc={"arr": ["a", "b", "c"], "search": "b", "start": 2, "end": 1}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + expected=-1, + msg="$indexOfArray should return -1 when end less than start", + ), + ExpressionTestCase( + "range_end_beyond_array", + doc={"arr": [1, 2, 3], "search": 3, "start": 0, "end": 100}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + expected=2, + msg="$indexOfArray should find when end beyond array", + ), + ExpressionTestCase( + "range_full_array", + doc={"arr": [1, 2, 3], "search": 2, "start": 0, "end": 3}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + expected=1, + msg="$indexOfArray should find in full array range", + ), + ExpressionTestCase( + "range_int64_bounds", + doc={"arr": [10, 20, 30], "search": 20, "start": INT64_ZERO, "end": Int64(3)}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + expected=1, + msg="$indexOfArray should accept Int64 bounds", + ), + ExpressionTestCase( + "range_double_integral_bounds", + doc={"arr": [10, 20, 30], "search": 20, "start": DOUBLE_ZERO, "end": 3.0}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + expected=1, + msg="$indexOfArray should accept integral double bounds", + ), + ExpressionTestCase( + "range_decimal128_bounds", + doc={ + "arr": [10, 20, 30], + "search": 20, + "start": DECIMAL128_ZERO, + "end": Decimal128("3"), + }, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + expected=1, + msg="$indexOfArray should accept Decimal128 bounds", + ), +] + +# Success: first occurrence from start with multiple duplicates +FIRST_FROM_START_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "dup_skip_to_third_occurrence", + doc={"arr": [5, 3, 5, 3, 5], "search": 5, "start": 3}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + expected=4, + msg="$indexOfArray should find third occurrence of 5 when start=3", + ), + ExpressionTestCase( + "dup_skip_different_value", + doc={"arr": [5, 3, 5, 3, 5], "search": 3, "start": 2}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + expected=3, + msg="$indexOfArray should find second 3 when start=2", + ), +] + +# Success: detailed range semantics +RANGE_SEMANTICS_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "end_exclusive_includes_before_boundary", + doc={"arr": ["a", "b", "c"], "search": "b", "start": 0, "end": 2}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + expected=1, + msg="$indexOfArray element before end boundary should be included in [0,2)", + ), + ExpressionTestCase( + "end_exclusive_excludes_at_boundary", + doc={"arr": ["a", "b", "c"], "search": "b", "start": 0, "end": 1}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + expected=-1, + msg="$indexOfArray element at end boundary should be excluded from [0,1)", + ), + ExpressionTestCase( + "empty_range_at_array_length", + doc={"arr": [1, 2, 3], "search": 3, "start": 3, "end": 3}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + expected=-1, + msg="$indexOfArray empty range [len,len) at array boundary should return -1", + ), + ExpressionTestCase( + "range_finds_dup_in_subrange", + doc={"arr": [1, 2, 3, 2, 1, 2, 3], "search": 2, "start": 2, "end": 4}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + expected=3, + msg="$indexOfArray should find value within range skipping earlier occurrences", + ), + ExpressionTestCase( + "start_at_array_length", + doc={"arr": ["a", "b", "c"], "search": "a", "start": 3}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + expected=-1, + msg="$indexOfArray should return -1 when start equals array length", + ), + ExpressionTestCase( + "start_and_end_both_beyond_array", + doc={"arr": ["a", "b", "c"], "search": "a", "start": 100, "end": 200}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + expected=-1, + msg="$indexOfArray should return -1 when both start and end are beyond array", + ), + ExpressionTestCase( + "end_before_match_position", + doc={"arr": ["a", "abc", "b"], "search": "b", "start": 0, "end": 1}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + expected=-1, + msg="$indexOfArray should return -1 when end is before the matching element", + ), +] + +# Success: degenerate and single-element edge cases +DEGENERATE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "single_not_found", + doc={"arr": [1], "search": 2}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=-1, + msg="$indexOfArray should return -1 when single element doesn't match", + ), + ExpressionTestCase( + "single_found_in_range", + doc={"arr": [42], "search": 42, "start": 0, "end": 1}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + expected=0, + msg="$indexOfArray single element found in range [0,1)", + ), + ExpressionTestCase( + "single_empty_range", + doc={"arr": [42], "search": 42, "start": 0, "end": 0}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + expected=-1, + msg="$indexOfArray single element not found in empty range [0,0)", + ), + ExpressionTestCase( + "single_start_past_element", + doc={"arr": [42], "search": 42, "start": 1}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + expected=-1, + msg="$indexOfArray single element not found when start past it", + ), + ExpressionTestCase( + "all_null_from_start", + doc={"arr": [None, None, None], "search": None, "start": 1}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + expected=1, + msg="$indexOfArray should find null at index 1 from start=1 in all-null array", + ), + ExpressionTestCase( + "all_null_search_different_type", + doc={"arr": [None, None, None], "search": 1}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=-1, + msg="$indexOfArray should not find int in all-null array", + ), + ExpressionTestCase( + "all_true_search_false", + doc={"arr": [True, True, True], "search": False}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=-1, + msg="$indexOfArray should not find false in all-true array", + ), +] + +# Success: mixed types in array +MIXED_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "mixed_find_string", + doc={"arr": [1, "2", True, None, [1]], "search": "2"}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=1, + msg="$indexOfArray should find string in mixed array", + ), + ExpressionTestCase( + "mixed_find_null", + doc={"arr": [1, "2", True, None, [1]], "search": None}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=3, + msg="$indexOfArray should find null in mixed array", + ), + ExpressionTestCase( + "mixed_find_array", + doc={"arr": [1, "2", True, None, [1]], "search": [1]}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=4, + msg="$indexOfArray should find array in mixed array", + ), +] + +# Success: large array +LARGE_ARRAY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "large_array_first", + doc={"arr": list(range(20_000)), "search": 0}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should find first in large array", + ), + ExpressionTestCase( + "large_array_last", + doc={"arr": list(range(20_000)), "search": 19_999}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=19_999, + msg="$indexOfArray should find last in large array", + ), + ExpressionTestCase( + "large_array_middle", + doc={"arr": list(range(20_000)), "search": 10_000}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=10_000, + msg="$indexOfArray should find middle in large array", + ), + ExpressionTestCase( + "large_array_not_found", + doc={"arr": list(range(20_000)), "search": -1}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=-1, + msg="$indexOfArray should return -1 for absent value in large array", + ), + ExpressionTestCase( + "large_array_with_start", + doc={"arr": list(range(20_000)), "search": 19_999, "start": 19_998}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + expected=19_999, + msg="$indexOfArray should find with start in large array", + ), +] + +# Negative zero treated as equivalent to positive zero in search +NEGATIVE_ZERO_SEARCH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "search_double_neg_zero_in_zeros", + doc={"arr": [0, 1, 2], "search": DOUBLE_NEGATIVE_ZERO}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should find -0.0 at index of 0", + ), + ExpressionTestCase( + "search_zero_finds_neg_zero", + doc={"arr": [DOUBLE_NEGATIVE_ZERO, 1, 2], "search": 0}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should find 0 matching -0.0 in array", + ), + ExpressionTestCase( + "search_decimal128_neg_zero", + doc={"arr": [0, 1, 2], "search": DECIMAL128_NEGATIVE_ZERO}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should find Decimal128 -0 at index of 0", + ), +] + +# Boundary values for start/end indices +BOUNDARY_INDEX_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "start_int32_max", + doc={"arr": [1, 2, 3], "search": 1, "start": INT32_MAX}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + expected=-1, + msg="$indexOfArray should return -1 with INT32_MAX start", + ), + ExpressionTestCase( + "end_int32_max", + doc={"arr": [1, 2, 3], "search": 2, "start": 0, "end": INT32_MAX}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + expected=1, + msg="$indexOfArray should find with INT32_MAX end", + ), + ExpressionTestCase( + "start_and_end_int32_max", + doc={"arr": [1, 2, 3], "search": 1, "start": INT32_MAX, "end": INT32_MAX}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + expected=-1, + msg="$indexOfArray should return -1 with both INT32_MAX", + ), + ExpressionTestCase( + "start_int32_max_minus_1", + doc={"arr": [1, 2, 3], "search": 1, "start": INT32_MAX - 1}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + expected=-1, + msg="$indexOfArray should return -1 with INT32_MAX-1 start", + ), +] + +# Negative zero as start/end index treated as 0 +NEGATIVE_ZERO_INDEX_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "double_neg_zero_start", + doc={"arr": [10, 20, 30], "search": 10, "start": DOUBLE_NEGATIVE_ZERO}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + expected=0, + msg="$indexOfArray should treat -0.0 start as 0", + ), + ExpressionTestCase( + "decimal128_neg_zero_start", + doc={"arr": [10, 20, 30], "search": 10, "start": DECIMAL128_NEGATIVE_ZERO}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + expected=0, + msg="$indexOfArray should treat decimal128 -0 start as 0", + ), + ExpressionTestCase( + "double_neg_zero_end", + doc={"arr": [10, 20, 30], "search": 10, "start": 0, "end": DOUBLE_NEGATIVE_ZERO}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + expected=-1, + msg="$indexOfArray should treat -0.0 end as 0", + ), +] + +# Aggregate and test +ALL_TESTS = ( + BASIC_FOUND_TESTS + + NOT_FOUND_TESTS + + START_INDEX_TESTS + + START_END_INDEX_TESTS + + FIRST_FROM_START_TESTS + + RANGE_SEMANTICS_TESTS + + DEGENERATE_TESTS + + MIXED_TYPE_TESTS + + LARGE_ARRAY_TESTS + + NEGATIVE_ZERO_SEARCH_TESTS + + BOUNDARY_INDEX_TESTS + + NEGATIVE_ZERO_INDEX_TESTS +) + +# Property [Literal Evaluation]: $indexOfArray evaluates correctly with inline literal values. +TEST_SUBSET_FOR_LITERAL: list[ExpressionTestCase] = [ + ExpressionTestCase( + "found_first", + expression={"$indexOfArray": [[1, 2, 3], 1]}, + expected=0, + msg="$indexOfArray should find first element at index 0", + ), + ExpressionTestCase( + "not_found_int", + expression={"$indexOfArray": [[1, 2, 3], 4]}, + expected=-1, + msg="$indexOfArray should return -1 for absent int", + ), + ExpressionTestCase( + "range_found", + expression={"$indexOfArray": [["a", "b", "c", "b"], "b", 1, 3]}, + expected=1, + msg="$indexOfArray should find in range", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(TEST_SUBSET_FOR_LITERAL)) +def test_indexOfArray_literal(collection, test): + """Test $indexOfArray with literal values.""" + result = execute_expression(collection, test.expression) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_indexOfArray_insert(collection, test): + """Test $indexOfArray with values from inserted documents.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/indexOfArray/test_indexOfArray_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/indexOfArray/test_indexOfArray_errors.py new file mode 100644 index 000000000..5fcf66bcd --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/indexOfArray/test_indexOfArray_errors.py @@ -0,0 +1,524 @@ +""" +Error tests for $indexOfArray expression. + +Tests non-array first argument, non-integral start/end, negative start/end, +boundary values, negative zero, and wrong arity errors. +""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + EXPRESSION_ARITY_ERROR, + INDEX_OF_ARRAY_INDEX_NEGATIVE_ERROR, + INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + INDEX_OF_ARRAY_NOT_ARRAY_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_HALF, + DECIMAL128_INFINITY, + DECIMAL128_NAN, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + INT64_MAX, + MISSING, +) + +# Error: INT64_MAX start/end index (not representable as int32) +BOUNDARY_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "start_int64_max", + doc={"arr": [1, 2, 3], "search": 1, "start": INT64_MAX}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="$indexOfArray should reject INT64_MAX start", + ), + ExpressionTestCase( + "end_int64_max", + doc={"arr": [1, 2, 3], "search": 2, "start": 0, "end": INT64_MAX}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="$indexOfArray should reject INT64_MAX end", + ), +] + +# Error: first argument not an array (and not null) +NOT_ARRAY_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "string_as_array", + doc={"arr": "hello", "search": 1}, + expression={"$indexOfArray": ["$arr", "$search"]}, + error_code=INDEX_OF_ARRAY_NOT_ARRAY_ERROR, + msg="$indexOfArray should reject string as array", + ), + ExpressionTestCase( + "int_as_array", + doc={"arr": 42, "search": 1}, + expression={"$indexOfArray": ["$arr", "$search"]}, + error_code=INDEX_OF_ARRAY_NOT_ARRAY_ERROR, + msg="$indexOfArray should reject int as array", + ), + ExpressionTestCase( + "double_as_array", + doc={"arr": 3.14, "search": 1}, + expression={"$indexOfArray": ["$arr", "$search"]}, + error_code=INDEX_OF_ARRAY_NOT_ARRAY_ERROR, + msg="$indexOfArray should reject double as array", + ), + ExpressionTestCase( + "bool_true_as_array", + doc={"arr": True, "search": 1}, + expression={"$indexOfArray": ["$arr", "$search"]}, + error_code=INDEX_OF_ARRAY_NOT_ARRAY_ERROR, + msg="$indexOfArray should reject bool true as array", + ), + ExpressionTestCase( + "bool_false_as_array", + doc={"arr": False, "search": 1}, + expression={"$indexOfArray": ["$arr", "$search"]}, + error_code=INDEX_OF_ARRAY_NOT_ARRAY_ERROR, + msg="$indexOfArray should reject bool false as array", + ), + ExpressionTestCase( + "object_as_array", + doc={"arr": {"a": 1}, "search": 1}, + expression={"$indexOfArray": ["$arr", "$search"]}, + error_code=INDEX_OF_ARRAY_NOT_ARRAY_ERROR, + msg="$indexOfArray should reject object as array", + ), + ExpressionTestCase( + "decimal128_as_array", + doc={"arr": Decimal128("1"), "search": 1}, + expression={"$indexOfArray": ["$arr", "$search"]}, + error_code=INDEX_OF_ARRAY_NOT_ARRAY_ERROR, + msg="$indexOfArray should reject decimal128 as array", + ), + ExpressionTestCase( + "int64_as_array", + doc={"arr": Int64(1), "search": 1}, + expression={"$indexOfArray": ["$arr", "$search"]}, + error_code=INDEX_OF_ARRAY_NOT_ARRAY_ERROR, + msg="$indexOfArray should reject int64 as array", + ), + ExpressionTestCase( + "binary_as_array", + doc={"arr": Binary(b"x", 0), "search": 1}, + expression={"$indexOfArray": ["$arr", "$search"]}, + error_code=INDEX_OF_ARRAY_NOT_ARRAY_ERROR, + msg="$indexOfArray should reject binary as array", + ), + ExpressionTestCase( + "datetime_as_array", + doc={"arr": datetime(2024, 1, 1, tzinfo=timezone.utc), "search": 1}, + expression={"$indexOfArray": ["$arr", "$search"]}, + error_code=INDEX_OF_ARRAY_NOT_ARRAY_ERROR, + msg="$indexOfArray should reject datetime as array", + ), + ExpressionTestCase( + "objectid_as_array", + doc={"arr": ObjectId(), "search": 1}, + expression={"$indexOfArray": ["$arr", "$search"]}, + error_code=INDEX_OF_ARRAY_NOT_ARRAY_ERROR, + msg="$indexOfArray should reject objectid as array", + ), + ExpressionTestCase( + "regex_as_array", + doc={"arr": Regex("x"), "search": 1}, + expression={"$indexOfArray": ["$arr", "$search"]}, + error_code=INDEX_OF_ARRAY_NOT_ARRAY_ERROR, + msg="$indexOfArray should reject regex as array", + ), + ExpressionTestCase( + "maxkey_as_array", + doc={"arr": MaxKey(), "search": 1}, + expression={"$indexOfArray": ["$arr", "$search"]}, + error_code=INDEX_OF_ARRAY_NOT_ARRAY_ERROR, + msg="$indexOfArray should reject maxkey as array", + ), + ExpressionTestCase( + "minkey_as_array", + doc={"arr": MinKey(), "search": 1}, + expression={"$indexOfArray": ["$arr", "$search"]}, + error_code=INDEX_OF_ARRAY_NOT_ARRAY_ERROR, + msg="$indexOfArray should reject minkey as array", + ), + ExpressionTestCase( + "timestamp_as_array", + doc={"arr": Timestamp(0, 0), "search": 1}, + expression={"$indexOfArray": ["$arr", "$search"]}, + error_code=INDEX_OF_ARRAY_NOT_ARRAY_ERROR, + msg="$indexOfArray should reject timestamp as array", + ), +] + +# Error: start index not integral +START_NOT_INTEGRAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "start_fractional_double", + doc={"arr": [1, 2, 3], "search": 1, "start": 1.5}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="$indexOfArray should reject fractional double start", + ), + ExpressionTestCase( + "start_fractional_decimal128", + doc={"arr": [1, 2, 3], "search": 1, "start": DECIMAL128_HALF}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="$indexOfArray should reject fractional decimal128 start", + ), + ExpressionTestCase( + "start_nan", + doc={"arr": [1, 2, 3], "search": 1, "start": FLOAT_NAN}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="$indexOfArray should reject NaN start", + ), + ExpressionTestCase( + "start_inf", + doc={"arr": [1, 2, 3], "search": 1, "start": FLOAT_INFINITY}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="$indexOfArray should reject infinity start", + ), + ExpressionTestCase( + "start_neg_inf", + doc={"arr": [1, 2, 3], "search": 1, "start": FLOAT_NEGATIVE_INFINITY}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="$indexOfArray should reject -infinity start", + ), + ExpressionTestCase( + "start_decimal128_nan", + doc={"arr": [1, 2, 3], "search": 1, "start": DECIMAL128_NAN}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="$indexOfArray should reject decimal128 NaN start", + ), + ExpressionTestCase( + "start_decimal128_inf", + doc={"arr": [1, 2, 3], "search": 1, "start": DECIMAL128_INFINITY}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="$indexOfArray should reject decimal128 infinity start", + ), + ExpressionTestCase( + "start_string", + doc={"arr": [1, 2, 3], "search": 1, "start": "0"}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="$indexOfArray should reject string start", + ), + ExpressionTestCase( + "start_bool", + doc={"arr": [1, 2, 3], "search": 1, "start": True}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="$indexOfArray should reject bool start", + ), + ExpressionTestCase( + "start_array", + doc={"arr": [1, 2, 3], "search": 1, "start": [0]}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="$indexOfArray should reject array start", + ), + ExpressionTestCase( + "start_object", + doc={"arr": [1, 2, 3], "search": 1, "start": {"a": 0}}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="$indexOfArray should reject object start", + ), +] + +# Error: end index not integral +END_NOT_INTEGRAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "end_fractional_double", + doc={"arr": [1, 2, 3], "search": 1, "start": 0, "end": 1.5}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="$indexOfArray should reject fractional double end", + ), + ExpressionTestCase( + "end_fractional_decimal128", + doc={"arr": [1, 2, 3], "search": 1, "start": 0, "end": DECIMAL128_HALF}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="$indexOfArray should reject fractional decimal128 end", + ), + ExpressionTestCase( + "end_nan", + doc={"arr": [1, 2, 3], "search": 1, "start": 0, "end": FLOAT_NAN}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="$indexOfArray should reject NaN end", + ), + ExpressionTestCase( + "end_inf", + doc={"arr": [1, 2, 3], "search": 1, "start": 0, "end": FLOAT_INFINITY}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="$indexOfArray should reject infinity end", + ), + ExpressionTestCase( + "end_string", + doc={"arr": [1, 2, 3], "search": 1, "start": 0, "end": "3"}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="$indexOfArray should reject string end", + ), + ExpressionTestCase( + "end_bool", + doc={"arr": [1, 2, 3], "search": 1, "start": 0, "end": True}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="$indexOfArray should reject bool end", + ), + ExpressionTestCase( + "end_neg_inf", + doc={"arr": [1, 2, 3], "search": 1, "start": 0, "end": FLOAT_NEGATIVE_INFINITY}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="$indexOfArray should reject -infinity end", + ), + ExpressionTestCase( + "end_decimal128_nan", + doc={"arr": [1, 2, 3], "search": 1, "start": 0, "end": DECIMAL128_NAN}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="$indexOfArray should reject decimal128 NaN end", + ), + ExpressionTestCase( + "end_decimal128_inf", + doc={"arr": [1, 2, 3], "search": 1, "start": 0, "end": DECIMAL128_INFINITY}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="$indexOfArray should reject decimal128 infinity end", + ), + ExpressionTestCase( + "end_array", + doc={"arr": [1, 2, 3], "search": 1, "start": 0, "end": [3]}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="$indexOfArray should reject array end", + ), + ExpressionTestCase( + "end_object", + doc={"arr": [1, 2, 3], "search": 1, "start": 0, "end": {"a": 0}}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="$indexOfArray should reject object end", + ), +] + +# Error: negative start index +START_NEGATIVE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "start_neg_one", + doc={"arr": [1, 2, 3], "search": 1, "start": -1}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + error_code=INDEX_OF_ARRAY_INDEX_NEGATIVE_ERROR, + msg="$indexOfArray should reject negative start -1", + ), + ExpressionTestCase( + "start_neg_large", + doc={"arr": [1, 2, 3], "search": 1, "start": -100}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + error_code=INDEX_OF_ARRAY_INDEX_NEGATIVE_ERROR, + msg="$indexOfArray should reject negative start -100", + ), + ExpressionTestCase( + "start_neg_int64", + doc={"arr": [1, 2, 3], "search": 1, "start": Int64(-1)}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + error_code=INDEX_OF_ARRAY_INDEX_NEGATIVE_ERROR, + msg="$indexOfArray should reject negative Int64 start", + ), + ExpressionTestCase( + "start_neg_double", + doc={"arr": [1, 2, 3], "search": 1, "start": -1.0}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + error_code=INDEX_OF_ARRAY_INDEX_NEGATIVE_ERROR, + msg="$indexOfArray should reject negative double start", + ), + ExpressionTestCase( + "start_neg_decimal128", + doc={"arr": [1, 2, 3], "search": 1, "start": Decimal128("-1")}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + error_code=INDEX_OF_ARRAY_INDEX_NEGATIVE_ERROR, + msg="$indexOfArray should reject negative decimal128 start", + ), +] + +# Error: negative end index +END_NEGATIVE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "end_neg_one", + doc={"arr": [1, 2, 3], "search": 1, "start": 0, "end": -1}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + error_code=INDEX_OF_ARRAY_INDEX_NEGATIVE_ERROR, + msg="$indexOfArray should reject negative end -1", + ), + ExpressionTestCase( + "end_neg_large", + doc={"arr": [1, 2, 3], "search": 1, "start": 0, "end": -100}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + error_code=INDEX_OF_ARRAY_INDEX_NEGATIVE_ERROR, + msg="$indexOfArray should reject negative end -100", + ), + ExpressionTestCase( + "end_neg_int64", + doc={"arr": [1, 2, 3], "search": 1, "start": 0, "end": Int64(-1)}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + error_code=INDEX_OF_ARRAY_INDEX_NEGATIVE_ERROR, + msg="$indexOfArray should reject negative Int64 end", + ), + ExpressionTestCase( + "end_neg_double", + doc={"arr": [1, 2, 3], "search": 1, "start": 0, "end": -1.0}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + error_code=INDEX_OF_ARRAY_INDEX_NEGATIVE_ERROR, + msg="$indexOfArray should reject negative double end", + ), + ExpressionTestCase( + "end_neg_decimal128", + doc={"arr": [1, 2, 3], "search": 1, "start": 0, "end": Decimal128("-1")}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + error_code=INDEX_OF_ARRAY_INDEX_NEGATIVE_ERROR, + msg="$indexOfArray should reject negative decimal128 end", + ), +] + +# Aggregate and test +ALL_TESTS = ( + BOUNDARY_ERROR_TESTS + + NOT_ARRAY_ERROR_TESTS + + START_NOT_INTEGRAL_TESTS + + END_NOT_INTEGRAL_TESTS + + START_NEGATIVE_TESTS + + END_NEGATIVE_TESTS +) + +# Property [Literal Evaluation]: error cases with inline literal values. +TEST_SUBSET_FOR_LITERAL: list[ExpressionTestCase] = [ + ExpressionTestCase( + "string_as_array", + expression={"$indexOfArray": ["hello", 1]}, + error_code=INDEX_OF_ARRAY_NOT_ARRAY_ERROR, + msg="$indexOfArray should reject string as array", + ), + ExpressionTestCase( + "start_fractional_double", + expression={"$indexOfArray": [[1, 2, 3], 1, 1.5]}, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="$indexOfArray should reject fractional double start", + ), + ExpressionTestCase( + "start_neg_one", + expression={"$indexOfArray": [[1, 2, 3], 1, -1]}, + error_code=INDEX_OF_ARRAY_INDEX_NEGATIVE_ERROR, + msg="$indexOfArray should reject negative start -1", + ), + ExpressionTestCase( + "start_missing_field", + expression={"$indexOfArray": [[1, 2, 3], 1, MISSING]}, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="$indexOfArray should reject missing field as start", + ), + ExpressionTestCase( + "end_missing_field", + expression={"$indexOfArray": [[1, 2, 3], 1, 0, MISSING]}, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="$indexOfArray should reject missing field as end", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(TEST_SUBSET_FOR_LITERAL)) +def test_indexOfArray_literal(collection, test): + """Test $indexOfArray error cases with literal values.""" + result = execute_expression(collection, test.expression) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_indexOfArray_insert(collection, test): + """Test $indexOfArray error cases with values from inserted documents.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +# Property [Arity]: $indexOfArray requires two to four arguments. +ARITY_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "zero_args", + expression={"$indexOfArray": []}, + error_code=EXPRESSION_ARITY_ERROR, + msg="$indexOfArray should reject zero arguments", + ), + ExpressionTestCase( + "one_arg", + expression={"$indexOfArray": [[1, 2, 3]]}, + error_code=EXPRESSION_ARITY_ERROR, + msg="$indexOfArray should reject one argument", + ), + ExpressionTestCase( + "five_args", + expression={"$indexOfArray": [[1, 2, 3], 1, 0, 3, 99]}, + error_code=EXPRESSION_ARITY_ERROR, + msg="$indexOfArray should reject five arguments", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(ARITY_ERROR_TESTS)) +def test_indexOfArray_arity_error(collection, test): + """Test $indexOfArray arity error cases.""" + result = execute_expression(collection, test.expression) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +# Property [Null Index]: $indexOfArray rejects null as start or end index. +NULL_INDEX_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_end", + expression={"$indexOfArray": [[1, 2, 3], 1, 0, None]}, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="$indexOfArray should reject null end", + ), + ExpressionTestCase( + "null_start", + expression={"$indexOfArray": [[1, 2, 3], 1, None]}, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="$indexOfArray should reject null start", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(NULL_INDEX_ERROR_TESTS)) +def test_indexOfArray_null_index_error(collection, test): + """Test $indexOfArray rejects null as start or end index.""" + result = execute_expression(collection, test.expression) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/indexOfArray/test_indexOfArray_expressions.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/indexOfArray/test_indexOfArray_expressions.py new file mode 100644 index 000000000..4f7d6662c --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/indexOfArray/test_indexOfArray_expressions.py @@ -0,0 +1,118 @@ +""" +Expression and field path tests for $indexOfArray expression. + +Tests nested expressions, field path lookups, composite paths, +and path through array of objects. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Nested Expressions]: $indexOfArray evaluates nested expressions as arguments. +NESTED_EXPRESSION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_2_level", + expression={ + "$indexOfArray": [ + [0, 1, 2, 3], + {"$indexOfArray": [[10, 20, 30], 30]}, + ] + }, + expected=2, + msg="$indexOfArray should use inner result as search value", + ), + ExpressionTestCase( + "nested_3_level", + expression={ + "$indexOfArray": [ + [0, 1, 2], + { + "$indexOfArray": [ + [0, 1, 2], + {"$indexOfArray": [[5, 10, 15], 10]}, + ] + }, + ] + }, + expected=1, + msg="$indexOfArray should support triple nested composition", + ), + ExpressionTestCase( + "nested_start_index", + expression={ + "$indexOfArray": [ + [1, 2, 1, 2], + 1, + {"$indexOfArray": [[10, 20, 30], 20]}, + ] + }, + expected=2, + msg="$indexOfArray should use nested result as start index", + ), +] + +# Property [Field Path Resolution]: $indexOfArray resolves nested and deeply nested field paths. +FIELD_LOOKUP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_field_path", + expression={"$indexOfArray": ["$a.b", 20]}, + doc={"a": {"b": [10, 20, 30]}}, + expected=1, + msg="$indexOfArray should resolve nested field path as array", + ), + ExpressionTestCase( + "nonexistent_field_null", + expression={"$indexOfArray": ["$a.nonexistent", 0]}, + doc={"a": {"missing": 1}}, + expected=None, + msg="$indexOfArray should return null for non-existent field", + ), + ExpressionTestCase( + "deeply_nested_field", + expression={"$indexOfArray": ["$a.b.c", 7]}, + doc={"a": {"b": {"c": [5, 6, 7]}}}, + expected=2, + msg="$indexOfArray should resolve deeply nested field path", + ), +] + +# Property [Composite Paths]: $indexOfArray resolves composite array paths from array-of-objects. +COMPOSITE_PATH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "composite_array_as_array", + expression={"$indexOfArray": ["$x.y", 20]}, + doc={"x": [{"y": 10}, {"y": 20}, {"y": 30}]}, + expected=1, + msg="$indexOfArray should find value in composite array path", + ), + ExpressionTestCase( + "composite_array_as_search", + expression={"$indexOfArray": [[[10, 20], [30, 40]], "$x.y"]}, + doc={"x": [{"y": 30}, {"y": 40}]}, + expected=1, + msg="$indexOfArray should match composite array as search value", + ), +] + +ALL_EXPRESSION_TESTS = NESTED_EXPRESSION_TESTS + FIELD_LOOKUP_TESTS + COMPOSITE_PATH_TESTS + + +@pytest.mark.parametrize("test", pytest_params(ALL_EXPRESSION_TESTS)) +def test_indexOfArray_expression(collection, test): + """Test $indexOfArray with expressions, field paths, and composite paths.""" + if test.doc is None: + result = execute_expression(collection, test.expression) + else: + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/indexOfArray/test_indexOfArray_null_missing.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/indexOfArray/test_indexOfArray_null_missing.py new file mode 100644 index 000000000..df42559dc --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/indexOfArray/test_indexOfArray_null_missing.py @@ -0,0 +1,107 @@ +""" +Null and missing field handling tests for $indexOfArray expression. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import MISSING + +# Property [Null Array]: $indexOfArray returns null when array is null or missing. +NULL_ARRAY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_array", + doc={"arr": None, "search": 1}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=None, + msg="$indexOfArray should return null for null array", + ), + ExpressionTestCase( + "null_array_with_start", + doc={"arr": None, "search": 1, "start": 0}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + expected=None, + msg="$indexOfArray should return null for null array with start", + ), +] + +# Property [Null Search]: $indexOfArray handles null and missing search values. +NULL_SEARCH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_value_in_array", + doc={"arr": [1, None, 3], "search": None}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=1, + msg="$indexOfArray should find null in array", + ), + ExpressionTestCase( + "null_value_not_in_array", + doc={"arr": [1, 2, 3], "search": None}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=-1, + msg="$indexOfArray should return -1 for null not in array", + ), +] + +# Property [Literal Evaluation]: null/missing handling with inline literal values. +TEST_SUBSET_FOR_LITERAL: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_array", + expression={"$indexOfArray": [None, 1]}, + expected=None, + msg="$indexOfArray should return null for null array", + ), + ExpressionTestCase( + "null_value_in_array", + expression={"$indexOfArray": [[1, None, 3], None]}, + expected=1, + msg="$indexOfArray should find null in array", + ), + ExpressionTestCase( + "missing_array", + expression={"$indexOfArray": [MISSING, 1]}, + expected=None, + msg="$indexOfArray should return null for missing array", + ), + ExpressionTestCase( + "missing_value", + expression={"$indexOfArray": [[1, 2, 3], MISSING]}, + expected=-1, + msg="$indexOfArray should return -1 for missing search value", + ), + ExpressionTestCase( + "missing_value_null_in_array", + expression={"$indexOfArray": [[1, None, 3], MISSING]}, + expected=-1, + msg="$indexOfArray should return -1 for missing search even with null in array", + ), +] + +# Aggregate and test +ALL_TESTS = NULL_ARRAY_TESTS + NULL_SEARCH_TESTS + + +@pytest.mark.parametrize("test", pytest_params(TEST_SUBSET_FOR_LITERAL)) +def test_indexOfArray_literal(collection, test): + """Test $indexOfArray null/missing with literal values.""" + result = execute_expression(collection, test.expression) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_indexOfArray_insert(collection, test): + """Test $indexOfArray null with values from inserted documents.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/indexOfArray/test_smoke_expression_indexOfArray.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/indexOfArray/test_smoke_indexOfArray.py similarity index 87% rename from documentdb_tests/compatibility/tests/core/operator/expressions/array/indexOfArray/test_smoke_expression_indexOfArray.py rename to documentdb_tests/compatibility/tests/core/operator/expressions/array/indexOfArray/test_smoke_indexOfArray.py index d92b4fb31..3b2492b85 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/array/indexOfArray/test_smoke_expression_indexOfArray.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/indexOfArray/test_smoke_indexOfArray.py @@ -31,4 +31,6 @@ def test_smoke_expression_indexOfArray(collection): ) expected = [{"_id": 1, "index": 1}, {"_id": 2, "index": 1}] - assertSuccess(result, expected, msg="Should support $indexOfArray expression") + assertSuccess( + result, expected, ignore_doc_order=True, msg="Should support $indexOfArray expression" + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/isArray/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/isArray/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/isArray/test_isArray_bson_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/isArray/test_isArray_bson_types.py new file mode 100644 index 000000000..dc15c40f4 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/isArray/test_isArray_bson_types.py @@ -0,0 +1,418 @@ +""" +BSON type tests for $isArray expression. + +Tests arrays containing specific BSON types return true, +non-array BSON types return false, special numeric values, +and boundary values. +""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_MAX, + DECIMAL128_MIN, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_NAN, + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ONE_AND_HALF, + DOUBLE_NEGATIVE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + INT32_MAX, + INT32_MIN, + INT64_MAX, + INT64_MIN, +) + +# Arrays containing specific BSON types → true +BSON_ARRAY_TRUE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "bindata_array", + doc={"val": [Binary(b"\x00", 0)]}, + expression={"$isArray": "$val"}, + expected=True, + msg="$isArray should return true for BinData array", + ), + ExpressionTestCase( + "timestamp_array", + doc={"val": [Timestamp(0, 0)]}, + expression={"$isArray": "$val"}, + expected=True, + msg="$isArray should return true for Timestamp array", + ), + ExpressionTestCase( + "int64_array", + doc={"val": [Int64(1)]}, + expression={"$isArray": "$val"}, + expected=True, + msg="$isArray should return true for Int64 array", + ), + ExpressionTestCase( + "decimal128_array", + doc={"val": [Decimal128("1")]}, + expression={"$isArray": "$val"}, + expected=True, + msg="$isArray should return true for Decimal128 array", + ), + ExpressionTestCase( + "objectid_array", + doc={"val": [ObjectId()]}, + expression={"$isArray": "$val"}, + expected=True, + msg="$isArray should return true for ObjectId array", + ), + ExpressionTestCase( + "datetime_array", + doc={"val": [datetime(2024, 1, 1, tzinfo=timezone.utc)]}, + expression={"$isArray": "$val"}, + expected=True, + msg="$isArray should return true for datetime array", + ), + ExpressionTestCase( + "minkey_array", + doc={"val": [MinKey()]}, + expression={"$isArray": "$val"}, + expected=True, + msg="$isArray should return true for MinKey array", + ), + ExpressionTestCase( + "maxkey_array", + doc={"val": [MaxKey()]}, + expression={"$isArray": "$val"}, + expected=True, + msg="$isArray should return true for MaxKey array", + ), + ExpressionTestCase( + "regex_array", + doc={"val": [Regex(".*")]}, + expression={"$isArray": "$val"}, + expected=True, + msg="$isArray should return true for Regex array", + ), + ExpressionTestCase( + "nan_array", + doc={"val": [FLOAT_NAN]}, + expression={"$isArray": "$val"}, + expected=True, + msg="$isArray should return true for NaN array", + ), + ExpressionTestCase( + "inf_array", + doc={"val": [FLOAT_INFINITY]}, + expression={"$isArray": "$val"}, + expected=True, + msg="$isArray should return true for Infinity array", + ), + ExpressionTestCase( + "decimal128_nan_array", + doc={"val": [DECIMAL128_NAN]}, + expression={"$isArray": "$val"}, + expected=True, + msg="$isArray should return true for Decimal128 NaN array", + ), + ExpressionTestCase( + "decimal128_inf_array", + doc={"val": [DECIMAL128_INFINITY]}, + expression={"$isArray": "$val"}, + expected=True, + msg="$isArray should return true for Decimal128 Infinity array", + ), + ExpressionTestCase( + "decimal128_neg_nan_array", + doc={"val": [DECIMAL128_NEGATIVE_NAN]}, + expression={"$isArray": "$val"}, + expected=True, + msg="$isArray should return true for Decimal128 -NaN array", + ), + ExpressionTestCase( + "decimal128_neg_inf_array", + doc={"val": [DECIMAL128_NEGATIVE_INFINITY]}, + expression={"$isArray": "$val"}, + expected=True, + msg="$isArray should return true for Decimal128 -Infinity array", + ), + ExpressionTestCase( + "neg_inf_array", + doc={"val": [FLOAT_NEGATIVE_INFINITY]}, + expression={"$isArray": "$val"}, + expected=True, + msg="$isArray should return true for -Infinity array", + ), + ExpressionTestCase( + "neg_zero_array", + doc={"val": [DOUBLE_NEGATIVE_ZERO]}, + expression={"$isArray": "$val"}, + expected=True, + msg="$isArray should return true for negative zero array", + ), + ExpressionTestCase( + "decimal128_neg_zero_array", + doc={"val": [DECIMAL128_NEGATIVE_ZERO]}, + expression={"$isArray": "$val"}, + expected=True, + msg="$isArray should return true for Decimal128 -0 array", + ), + ExpressionTestCase( + "nested_mixed_bson_array", + doc={ + "val": [ + MinKey(), + {"a": [DECIMAL128_ONE_AND_HALF]}, + Int64(1), + datetime(2024, 1, 1, tzinfo=timezone.utc), + Binary(b"\x01", 0), + ] + }, + expression={"$isArray": "$val"}, + expected=True, + msg="$isArray should return true for nested mixed BSON array", + ), +] + +# Non-array BSON types → false +BSON_FALSE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int64", + doc={"val": Int64(1)}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for Int64", + ), + ExpressionTestCase( + "decimal128", + doc={"val": Decimal128("1")}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for Decimal128", + ), + ExpressionTestCase( + "objectid", + doc={"val": ObjectId("000000000000000000000001")}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for ObjectId", + ), + ExpressionTestCase( + "datetime", + doc={"val": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for datetime", + ), + ExpressionTestCase( + "binary", + doc={"val": Binary(b"\x01", 0)}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for Binary", + ), + ExpressionTestCase( + "regex", + doc={"val": Regex("^abc")}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for Regex", + ), + ExpressionTestCase( + "timestamp", + doc={"val": Timestamp(1, 1)}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for Timestamp", + ), + ExpressionTestCase( + "minkey", + doc={"val": MinKey()}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for MinKey", + ), + ExpressionTestCase( + "maxkey", + doc={"val": MaxKey()}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for MaxKey", + ), +] + +# Special numeric values → false +SPECIAL_NUMERIC_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nan", + doc={"val": FLOAT_NAN}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for NaN", + ), + ExpressionTestCase( + "inf", + doc={"val": FLOAT_INFINITY}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for Infinity", + ), + ExpressionTestCase( + "neg_inf", + doc={"val": FLOAT_NEGATIVE_INFINITY}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for -Infinity", + ), + ExpressionTestCase( + "neg_zero", + doc={"val": DOUBLE_NEGATIVE_ZERO}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for negative zero", + ), + ExpressionTestCase( + "decimal128_nan", + doc={"val": DECIMAL128_NAN}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for Decimal128 NaN", + ), + ExpressionTestCase( + "decimal128_neg_nan", + doc={"val": DECIMAL128_NEGATIVE_NAN}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for Decimal128 -NaN", + ), + ExpressionTestCase( + "decimal128_inf", + doc={"val": DECIMAL128_INFINITY}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for Decimal128 Infinity", + ), + ExpressionTestCase( + "decimal128_neg_inf", + doc={"val": DECIMAL128_NEGATIVE_INFINITY}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for Decimal128 -Infinity", + ), + ExpressionTestCase( + "decimal128_neg_zero", + doc={"val": DECIMAL128_NEGATIVE_ZERO}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for Decimal128 -0", + ), +] + +# Boundary values → false +BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int32_max", + doc={"val": INT32_MAX}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for INT32_MAX", + ), + ExpressionTestCase( + "int32_min", + doc={"val": INT32_MIN}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for INT32_MIN", + ), + ExpressionTestCase( + "int64_max", + doc={"val": INT64_MAX}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for INT64_MAX", + ), + ExpressionTestCase( + "int64_min", + doc={"val": INT64_MIN}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for INT64_MIN", + ), + ExpressionTestCase( + "decimal128_max", + doc={"val": DECIMAL128_MAX}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for DECIMAL128_MAX", + ), + ExpressionTestCase( + "decimal128_min", + doc={"val": DECIMAL128_MIN}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for DECIMAL128_MIN", + ), +] + +# Aggregate and test +# Property [Literal Evaluation]: $isArray BSON type detection works with inline literal values. +TEST_SUBSET_FOR_LITERAL: list[ExpressionTestCase] = [ + ExpressionTestCase( + "literal_bindata_array", + doc=None, + expression={"$isArray": [{"$literal": [Binary(b"\x00", 0)]}]}, + expected=True, + msg="$isArray should return true for literal BinData array", + ), + ExpressionTestCase( + "literal_nested_mixed_bson_array", + doc=None, + expression={ + "$isArray": [{"$literal": [MinKey(), {"a": [DECIMAL128_ONE_AND_HALF]}, Int64(1)]}] + }, + expected=True, + msg="$isArray should return true for literal nested mixed BSON array", + ), + ExpressionTestCase( + "literal_int64", + doc=None, + expression={"$isArray": [Int64(1)]}, + expected=False, + msg="$isArray should return false for literal Int64", + ), + ExpressionTestCase( + "literal_nan", + doc=None, + expression={"$isArray": [FLOAT_NAN]}, + expected=False, + msg="$isArray should return false for literal NaN", + ), +] + +ALL_BSON_TESTS = ( + BSON_ARRAY_TRUE_TESTS + + BSON_FALSE_TESTS + + SPECIAL_NUMERIC_TESTS + + BOUNDARY_TESTS + + TEST_SUBSET_FOR_LITERAL +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_BSON_TESTS)) +def test_isArray_bson_insert(collection, test): + """Test $isArray BSON types with values from inserted documents.""" + if test.doc is None: + result = execute_expression(collection, test.expression) + else: + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result(result, expected=test.expected, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/isArray/test_isArray_core_behavior.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/isArray/test_isArray_core_behavior.py new file mode 100644 index 000000000..dd0725c80 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/isArray/test_isArray_core_behavior.py @@ -0,0 +1,325 @@ +""" +Core behavior tests for $isArray expression. + +Tests that arrays return true, non-arrays return false, +with basic types. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.lazy_payload import lazy +from documentdb_tests.framework.parametrize import pytest_params + +# Success: arrays → true +IS_ARRAY_TRUE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "simple_array", + doc={"val": [1, 2, 3]}, + expression={"$isArray": "$val"}, + expected=True, + msg="$isArray should return true for simple array", + ), + ExpressionTestCase( + "empty_array", + doc={"val": []}, + expression={"$isArray": "$val"}, + expected=True, + msg="$isArray should return true for empty array", + ), + ExpressionTestCase( + "single_element", + doc={"val": [42]}, + expression={"$isArray": "$val"}, + expected=True, + msg="$isArray should return true for single-element array", + ), + ExpressionTestCase( + "nested_array", + doc={"val": [[1, 2], [3, 4]]}, + expression={"$isArray": "$val"}, + expected=True, + msg="$isArray should return true for nested array", + ), + ExpressionTestCase( + "mixed_type_array", + doc={"val": [1, "two", True, None, {"a": 1}]}, + expression={"$isArray": "$val"}, + expected=True, + msg="$isArray should return true for mixed-type array", + ), + ExpressionTestCase( + "array_of_objects", + doc={"val": [{"a": 1}, {"b": 2}]}, + expression={"$isArray": "$val"}, + expected=True, + msg="$isArray should return true for array of objects", + ), + ExpressionTestCase( + "array_of_nulls", + doc={"val": [None, None]}, + expression={"$isArray": "$val"}, + expected=True, + msg="$isArray should return true for array of nulls", + ), + ExpressionTestCase( + "string_array", + doc={"val": ["a", "b", "c"]}, + expression={"$isArray": "$val"}, + expected=True, + msg="$isArray should return true for string array", + ), + ExpressionTestCase( + "bool_array", + doc={"val": [True]}, + expression={"$isArray": "$val"}, + expected=True, + msg="$isArray should return true for bool array", + ), + ExpressionTestCase( + "large_array_10k", + doc={"val": list(range(10000))}, + expression={"$isArray": "$val"}, + expected=True, + msg="$isArray 10K element array returns true", + ), + ExpressionTestCase( + "deeply_nested_array", + doc={"val": [[[[[[1]]]]]]}, + expression={"$isArray": "$val"}, + expected=True, + msg="$isArray deeply nested array returns true", + ), + ExpressionTestCase( + "large_array_of_arrays", + doc=lazy(lambda: {"val": [[i] for i in range(10000)]}), + expression={"$isArray": "$val"}, + expected=True, + msg="$isArray 10K nested arrays returns true", + ), +] + +# Success: non-arrays → false +IS_ARRAY_FALSE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "string", + doc={"val": "hello"}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for string", + ), + ExpressionTestCase( + "int", + doc={"val": 42}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for int", + ), + ExpressionTestCase( + "double", + doc={"val": 3.14}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for double", + ), + ExpressionTestCase( + "bool_true", + doc={"val": True}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for true", + ), + ExpressionTestCase( + "bool_false", + doc={"val": False}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for false", + ), + ExpressionTestCase( + "null", + doc={"val": None}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for null", + ), + ExpressionTestCase( + "object", + doc={"val": {"a": 1}}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for object", + ), + ExpressionTestCase( + "empty_string", + doc={"val": ""}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for empty string", + ), + ExpressionTestCase( + "empty_object", + doc={"val": {}}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for empty object", + ), + ExpressionTestCase( + "zero", + doc={"val": 0}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for zero", + ), + ExpressionTestCase( + "negative_int", + doc={"val": -123}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for negative int", + ), + ExpressionTestCase( + "negative_double", + doc={"val": -1.23}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for negative double", + ), +] + +# Array-like edge cases → false +ARRAY_LIKE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "string_brackets", + doc={"val": "[]"}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for string '[]'", + ), + ExpressionTestCase( + "string_array_repr", + doc={"val": "[1, 2, 3]"}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for string '[1, 2, 3]'", + ), + ExpressionTestCase( + "array_like_object", + doc={"val": {"0": "a", "1": "b"}}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for array-like object", + ), + ExpressionTestCase( + "length_object", + doc={"val": {"length": 3}}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for object with length key", + ), +] + +# Aggregate and test +# Property [Literal Evaluation]: $isArray evaluates correctly with inline literal values. +TEST_SUBSET_FOR_LITERAL: list[ExpressionTestCase] = [ + ExpressionTestCase( + "literal_simple_array", + doc=None, + expression={"$isArray": [{"$literal": [1, 2, 3]}]}, + expected=True, + msg="$isArray should return true for literal simple array", + ), + ExpressionTestCase( + "literal_empty_array", + doc=None, + expression={"$isArray": [{"$literal": []}]}, + expected=True, + msg="$isArray should return true for literal empty array", + ), + ExpressionTestCase( + "literal_nested_array", + doc=None, + expression={"$isArray": [{"$literal": [[1], [2]]}]}, + expected=True, + msg="$isArray should return true for literal nested array", + ), + ExpressionTestCase( + "literal_array_of_objects", + doc=None, + expression={"$isArray": [{"$literal": [{"a": 1}, {"b": 2}]}]}, + expected=True, + msg="$isArray should return true for literal array of objects", + ), + ExpressionTestCase( + "literal_array_of_nulls", + doc=None, + expression={"$isArray": [{"$literal": [None, None]}]}, + expected=True, + msg="$isArray should return true for literal array of nulls", + ), + ExpressionTestCase( + "literal_string", + doc=None, + expression={"$isArray": ["hello"]}, + expected=False, + msg="$isArray should return false for literal string", + ), + ExpressionTestCase( + "literal_int", + doc=None, + expression={"$isArray": [42]}, + expected=False, + msg="$isArray should return false for literal int", + ), + ExpressionTestCase( + "literal_bool_true", + doc=None, + expression={"$isArray": [True]}, + expected=False, + msg="$isArray should return false for literal true", + ), + ExpressionTestCase( + "literal_bool_false", + doc=None, + expression={"$isArray": [False]}, + expected=False, + msg="$isArray should return false for literal false", + ), + ExpressionTestCase( + "literal_null", + doc=None, + expression={"$isArray": [None]}, + expected=False, + msg="$isArray should return false for literal null", + ), + ExpressionTestCase( + "literal_object", + doc=None, + expression={"$isArray": [{"$literal": {"a": 1}}]}, + expected=False, + msg="$isArray should return false for literal object", + ), +] + +INSERT_TESTS = ( + IS_ARRAY_TRUE_TESTS + IS_ARRAY_FALSE_TESTS + ARRAY_LIKE_TESTS + TEST_SUBSET_FOR_LITERAL +) + + +@pytest.mark.parametrize("test", pytest_params(INSERT_TESTS)) +def test_isArray_insert(collection, test): + """Test $isArray with values from inserted documents.""" + if test.doc is None: + result = execute_expression(collection, test.expression) + else: + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/isArray/test_isArray_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/isArray/test_isArray_errors.py new file mode 100644 index 000000000..ade4b93ad --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/isArray/test_isArray_errors.py @@ -0,0 +1,50 @@ +""" +Error and argument handling tests for $isArray expression. + +Tests arity errors. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, +) +from documentdb_tests.framework.error_codes import EXPRESSION_TYPE_MISMATCH_ERROR +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Arity]: $isArray requires exactly one argument. +ARITY_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "zero_args", + expression={"$isArray": []}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="$isArray should reject zero arguments", + ), + ExpressionTestCase( + "two_args", + expression={"$isArray": [1, 2]}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="$isArray should reject two arguments", + ), + ExpressionTestCase( + "three_args", + expression={"$isArray": [1, 2, 3]}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="$isArray should reject three arguments", + ), +] + +ALL_ERROR_TESTS = ARITY_ERROR_TESTS + + +@pytest.mark.parametrize("test", pytest_params(ALL_ERROR_TESTS)) +def test_isArray_error(collection, test): + """Test $isArray error cases.""" + result = execute_expression(collection, test.expression) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/isArray/test_isArray_expressions.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/isArray/test_isArray_expressions.py new file mode 100644 index 000000000..f26f7b865 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/isArray/test_isArray_expressions.py @@ -0,0 +1,165 @@ +""" +Expression, field path, and variable tests for $isArray expression. + +Tests nested expressions, field path lookups, composite paths, +null/missing handling, self-nesting, system variables, +and large input. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +SELF_NESTING_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "self_nested", + expression={"$isArray": [{"$isArray": "$arr"}]}, + doc={"arr": [1, 2]}, + expected=False, + msg="$isArray inner returns bool, outer returns false", + ), +] + +# Field path lookups +FIELD_LOOKUP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_field_array", + expression={"$isArray": "$a.b"}, + doc={"a": {"b": [1, 2]}}, + expected=True, + msg="$isArray nested array field", + ), + ExpressionTestCase( + "deeply_nested_field_array", + expression={"$isArray": "$a.b.c"}, + doc={"a": {"b": {"c": [1]}}}, + expected=True, + msg="$isArray deeply nested array field", + ), + ExpressionTestCase( + "numeric_index_on_object_key", + expression={"$isArray": "$a.0.b"}, + doc={"a": {"0": {"b": [1]}}}, + expected=True, + msg="$isArray numeric key on object resolves to array", + ), +] + +# Composite array paths +COMPOSITE_PATH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "composite_array_path", + expression={"$isArray": "$a.b"}, + doc={"a": [{"b": 1}, {"b": 2}]}, + expected=True, + msg="$isArray composite array path should resolve to array", + ), + ExpressionTestCase( + "composite_empty_array", + expression={"$isArray": "$a.b"}, + doc={"a": []}, + expected=True, + msg="$isArray composite on empty array resolves to empty array", + ), + ExpressionTestCase( + "composite_three_elements", + expression={"$isArray": "$a.b"}, + doc={"a": [{"b": 1}, {"b": 2}, {"b": 3}]}, + expected=True, + msg="$isArray three-element composite resolves to array", + ), +] + +# Deep composite array traversal +DEEP_COMPOSITE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "array_at_leaf", + expression={"$isArray": "$a.b.c.d"}, + doc={"a": {"b": {"c": {"d": [1, 2, 3]}}}}, + expected=True, + msg="$isArray array at leaf level", + ), + ExpressionTestCase( + "array_at_c", + expression={"$isArray": "$a.b.c.d"}, + doc={"a": {"b": {"c": [{"d": 1}]}}}, + expected=True, + msg="$isArray array at c level", + ), +] + +# Null and missing handling +NULL_MISSING_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "missing_field", + expression={"$isArray": "$nonexistent"}, + doc={"other": 1}, + expected=False, + msg="$isArray missing field should return false", + ), + ExpressionTestCase( + "missing_nested_partial_path", + expression={"$isArray": "$a.b"}, + doc={"a": 1}, + expected=False, + msg="$isArray nested field on scalar parent returns false", + ), + ExpressionTestCase( + "missing_nested_empty_doc", + expression={"$isArray": "$a.b"}, + doc={"x": 1}, + expected=False, + msg="$isArray missing nested field returns false", + ), +] + +# System variables +SYSTEM_VAR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "root_variable", + expression={"$isArray": "$$ROOT"}, + doc={"a": 1}, + expected=False, + msg="$$ROOT is object, returns false", + ), + ExpressionTestCase( + "current_variable", + expression={"$isArray": "$$CURRENT"}, + doc={"a": 1}, + expected=False, + msg="$$CURRENT is object, returns false", + ), + ExpressionTestCase( + "let_array_variable", + expression={"$let": {"vars": {"x": "$arr"}, "in": {"$isArray": "$$x"}}}, + doc={"arr": [1, 2]}, + expected=True, + msg="$let var bound to array returns true", + ), +] + +# Aggregate and test +ALL_EXPR_TESTS = ( + FIELD_LOOKUP_TESTS + + COMPOSITE_PATH_TESTS + + DEEP_COMPOSITE_TESTS + + NULL_MISSING_TESTS + + SYSTEM_VAR_TESTS + + SELF_NESTING_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_EXPR_TESTS)) +def test_isArray_expression(collection, test): + """Test $isArray with field paths and expressions.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/isArray/test_smoke_expression_isArray.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/isArray/test_smoke_isArray.py similarity index 88% rename from documentdb_tests/compatibility/tests/core/operator/expressions/array/isArray/test_smoke_expression_isArray.py rename to documentdb_tests/compatibility/tests/core/operator/expressions/array/isArray/test_smoke_isArray.py index c16ab7d55..e705f17a9 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/array/isArray/test_smoke_expression_isArray.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/isArray/test_smoke_isArray.py @@ -26,4 +26,4 @@ def test_smoke_expression_isArray(collection): ) expected = [{"_id": 1, "isArray": True}, {"_id": 2, "isArray": False}] - assertSuccess(result, expected, msg="Should support $isArray expression") + assertSuccess(result, expected, ignore_doc_order=True, msg="Should support $isArray expression") diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_array_arrayElemAt_indexOfArray_in_slice.py b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_array_arrayElemAt_indexOfArray_in_slice.py new file mode 100644 index 000000000..e0ce66eaf --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_array_arrayElemAt_indexOfArray_in_slice.py @@ -0,0 +1,362 @@ +""" +Combination tests for array expression operators: $arrayElemAt, $indexOfArray, $in, $slice. + +Tests that verify these operators work correctly when composed with each other +and with other operators like $concatArrays, $reverseArray, $filter, $map, $size, etc. +""" + +import pytest +from bson import Decimal128, MaxKey, MinKey, Regex + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, +) +from documentdb_tests.framework.parametrize import pytest_params + +ARRAY_ELEM_AT_COMBINATION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "arrayElemAt_index_from_indexOfArray", + expression={"$arrayElemAt": [[10, 20, 30], {"$indexOfArray": [[10, 20, 30], 30]}]}, + expected=30, + msg="$indexOfArray should use $indexOfArray result as index", + ), + ExpressionTestCase( + "arrayElemAt_last_element_via_size", + expression={"$arrayElemAt": [[10, 20, 30], {"$subtract": [{"$size": [[10, 20, 30]]}, 1]}]}, + expected=30, + msg="$indexOfArray should access last element via $size - 1", + ), + ExpressionTestCase( + "arrayElemAt_elem_from_slice", + expression={"$arrayElemAt": [{"$slice": [[10, 20, 30, 40], -2]}, 0]}, + expected=30, + msg="$indexOfArray should access element from $slice result", + ), + ExpressionTestCase( + "arrayElemAt_elem_from_slice_3arg", + expression={"$arrayElemAt": [{"$slice": [[10, 20, 30, 40], 1, 2]}, 1]}, + expected=30, + msg="$indexOfArray should access element from $slice 3-arg result", + ), + ExpressionTestCase( + "arrayElemAt_elem_from_reverseArray", + expression={"$arrayElemAt": [{"$reverseArray": [[10, 20, 30]]}, 0]}, + expected=30, + msg="$indexOfArray should access element from $reverseArray result", + ), + ExpressionTestCase( + "arrayElemAt_elem_from_concatArrays", + expression={"$arrayElemAt": [{"$concatArrays": [[10, 20], [30, 40]]}, 2]}, + expected=30, + msg="$indexOfArray should access element from $concatArrays result", + ), + ExpressionTestCase( + "arrayElemAt_computed_index", + expression={"$arrayElemAt": [[10, 20, 30], {"$subtract": [3, 1]}]}, + expected=30, + msg="$indexOfArray should use computed index from $subtract", + ), +] + +# $in combinations +IN_COMBINATION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "in_value_from_add", + expression={"$in": [{"$add": [1, 1]}, [1, 2, 3]]}, + expected=True, + msg="$indexOfArray should find value computed by $add", + ), + ExpressionTestCase( + "in_array_from_concatArrays", + expression={"$in": [3, {"$concatArrays": [[1, 2], [3, 4]]}]}, + expected=True, + msg="$indexOfArray should search in $concatArrays result", + ), + ExpressionTestCase( + "in_value_from_arrayElemAt", + expression={"$in": [{"$arrayElemAt": [[10, 20, 30], 1]}, [5, 20, 35]]}, + expected=True, + msg="$indexOfArray should find value from $arrayElemAt", + ), + ExpressionTestCase( + "in_array_from_filter", + expression={ + "$in": [ + 4, + { + "$filter": { + "input": [1, 2, 3, 4, 5], + "as": "n", + "cond": {"$gte": ["$$n", 3]}, + } + }, + ] + }, + expected=True, + msg="$indexOfArray should search in $filter result", + ), + ExpressionTestCase( + "in_array_from_map", + expression={ + "$in": [ + 20, + { + "$map": { + "input": [1, 2, 3], + "as": "n", + "in": {"$multiply": ["$$n", 10]}, + } + }, + ] + }, + expected=True, + msg="$indexOfArray should search in $map result", + ), + ExpressionTestCase( + "in_array_from_reverseArray", + expression={"$in": [1, {"$reverseArray": [[1, 2, 3]]}]}, + expected=True, + msg="$indexOfArray should search in $reverseArray result", + ), + ExpressionTestCase( + "in_cond_with_inner_in", + expression={"$in": [5, {"$cond": [{"$in": ["a", ["a", "b"]]}, [5, 6], [7, 8]]}]}, + expected=True, + msg="$indexOfArray should search in $cond-selected array", + ), + ExpressionTestCase( + "in_inside_cond", + expression={"$cond": [{"$in": [2, [1, 2, 3]]}, "found", "not_found"]}, + expected="found", + msg="$indexOfArray should use $in result in $cond", + ), + ExpressionTestCase( + "in_value_from_indexOfArray", + expression={"$in": [{"$indexOfArray": [[10, 20, 30], 20]}, [0, 1, 2]]}, + expected=True, + msg="$indexOfArray should find $indexOfArray result in array", + ), + ExpressionTestCase( + "in_nested_decimal128", + expression={ + "$in": [ + {"$arrayElemAt": [[Decimal128("1.1"), Decimal128("2.2")], 1]}, + [Decimal128("2.2"), Decimal128("3.3")], + ] + }, + expected=True, + msg="$indexOfArray should find Decimal128 from $arrayElemAt in array", + ), +] + +# $indexOfArray combinations +INDEX_OF_ARRAY_COMBINATION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "indexOfArray_result_as_arrayElemAt_index", + expression={"$arrayElemAt": [[10, 20, 30], {"$indexOfArray": [[10, 20, 30], 20]}]}, + expected=20, + msg="$indexOfArray should use $indexOfArray result as $arrayElemAt index", + ), + ExpressionTestCase( + "indexOfArray_search_from_add", + expression={"$indexOfArray": [[1, 2, 3], {"$add": [1, 1]}]}, + expected=1, + msg="$indexOfArray should search for value computed by $add", + ), + ExpressionTestCase( + "indexOfArray_array_from_concatArrays", + expression={"$indexOfArray": [{"$concatArrays": [[1, 2], [3, 4]]}, 3]}, + expected=2, + msg="$indexOfArray should search in $concatArrays result", + ), + ExpressionTestCase( + "indexOfArray_array_from_filter", + expression={ + "$indexOfArray": [ + {"$filter": {"input": [1, 2, 3, 4, 5], "cond": {"$gt": ["$$this", 2]}}}, + 4, + ] + }, + expected=1, + msg="$indexOfArray should search in $filter result", + ), + ExpressionTestCase( + "indexOfArray_result_in_cond", + expression={ + "$cond": [{"$gte": [{"$indexOfArray": [[1, 2, 3], 2]}, 0]}, "found", "not_found"] + }, + expected="found", + msg="$indexOfArray should use $indexOfArray result in $cond", + ), + ExpressionTestCase( + "indexOfArray_start_from_subtract", + expression={"$indexOfArray": [[1, 2, 1, 2], 1, {"$subtract": [3, 1]}]}, + expected=2, + msg="$indexOfArray should use $subtract result as start index", + ), + ExpressionTestCase( + "indexOfArray_via_arrayElemAt", + expression={ + "$indexOfArray": [ + ["a", "b", "c", "d"], + { + "$arrayElemAt": [ + ["a", "b", "c", "d"], + {"$indexOfArray": [[10, 20, 30], 20]}, + ] + }, + ] + }, + expected=1, + msg="$indexOfArray should search for value from nested $arrayElemAt/$indexOfArray", + ), + ExpressionTestCase( + "indexOfArray_subarray_mixed_bson", + expression={ + "$indexOfArray": [ + [[MinKey(), MaxKey()], [1, 2], "x"], + { + "$arrayElemAt": [ + [[MinKey(), MaxKey()], [1, 2], "x"], + {"$indexOfArray": [[[MinKey(), MaxKey()], [1, 2], "x"], [1, 2]]}, + ] + }, + ] + }, + expected=1, + msg="$indexOfArray should find mixed BSON subarray via nested operators", + ), + ExpressionTestCase( + "indexOfArray_triple_nested_decimal128", + expression={ + "$indexOfArray": [ + [Decimal128("1.1"), Decimal128("2.2"), Decimal128("3.3")], + { + "$arrayElemAt": [ + [Decimal128("1.1"), Decimal128("2.2"), Decimal128("3.3")], + { + "$indexOfArray": [ + [Decimal128("1.1"), Decimal128("2.2"), Decimal128("3.3")], + Decimal128("3.3"), + ] + }, + ] + }, + ] + }, + expected=2, + msg="$indexOfArray should resolve triple-nested Decimal128 operators", + ), +] + +# $slice combinations +SLICE_COMBINATION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "slice_array_from_concatArrays", + expression={"$slice": [{"$concatArrays": [[1, 2], [3, 4, 5]]}, 3]}, + expected=[1, 2, 3], + msg="$indexOfArray should slice $concatArrays result", + ), + ExpressionTestCase( + "slice_n_from_subtract", + expression={"$slice": [[1, 2, 3, 4, 5], {"$subtract": [5, 2]}]}, + expected=[1, 2, 3], + msg="$indexOfArray should use $subtract result as n", + ), + ExpressionTestCase( + "slice_array_from_filter", + expression={ + "$slice": [ + { + "$filter": { + "input": [1, 2, 3, 4, 5], + "as": "n", + "cond": {"$gte": ["$$n", 3]}, + } + }, + 2, + ] + }, + expected=[3, 4], + msg="$indexOfArray should slice $filter result", + ), + ExpressionTestCase( + "slice_position_from_indexOfArray", + expression={ + "$slice": [ + [10, 20, 30, 40, 50], + {"$indexOfArray": [[10, 20, 30, 40, 50], 30]}, + 2, + ] + }, + expected=[30, 40], + msg="$indexOfArray should use $indexOfArray result as position", + ), + ExpressionTestCase( + "slice_array_from_map", + expression={ + "$slice": [ + { + "$map": { + "input": [1, 2, 3], + "as": "n", + "in": {"$multiply": ["$$n", 10]}, + } + }, + 2, + ] + }, + expected=[10, 20], + msg="$indexOfArray should slice $map result", + ), + ExpressionTestCase( + "slice_array_from_reverseArray", + expression={"$slice": [{"$reverseArray": [[1, 2, 3, 4, 5]]}, 3]}, + expected=[5, 4, 3], + msg="$indexOfArray should slice $reverseArray result", + ), + ExpressionTestCase( + "slice_n_from_size", + expression={ + "$slice": [[10, 20, 30, 40], {"$subtract": [{"$size": [[10, 20, 30, 40]]}, 1]}] + }, + expected=[10, 20, 30], + msg="$indexOfArray should use $size-based computation as n", + ), +] + +# Property [Type Preservation]: $arrayElemAt preserves element type and reports missing for OOB. +ARRAY_ELEM_AT_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "arrayElemAt_oob_is_missing", + expression={"$type": {"$arrayElemAt": [[1, 2, 3], 10]}}, + expected="missing", + msg="$arrayElemAt out-of-bounds should produce missing, not null", + ), + ExpressionTestCase( + "arrayElemAt_regex_type_preserved", + expression={"$type": {"$arrayElemAt": [[Regex("abc")], 0]}}, + expected="regex", + msg="$arrayElemAt should preserve the regex element type", + ), +] + +# Aggregate all combination tests +ALL_COMBINATION_TESTS = ( + ARRAY_ELEM_AT_COMBINATION_TESTS + + IN_COMBINATION_TESTS + + INDEX_OF_ARRAY_COMBINATION_TESTS + + SLICE_COMBINATION_TESTS + + ARRAY_ELEM_AT_TYPE_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_COMBINATION_TESTS)) +def test_combination_expression(collection, test): + """Test array operators composed with other operators.""" + result = execute_expression(collection, test.expression) + assert_expression_result(result, expected=test.expected, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_filter.py b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_filter.py new file mode 100644 index 000000000..73a5b7873 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_filter.py @@ -0,0 +1,65 @@ +""" +Combination tests for $filter composed with other operators. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +FILTER_COMBINATION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "filter_then_size", + expression={"$size": {"$filter": {"input": "$arr", "cond": {"$gt": ["$$this", 2]}}}}, + doc={"arr": [1, 2, 3, 4, 5]}, + expected=3, + msg="$filter size of filtered array", + ), + ExpressionTestCase( + "map_then_filter", + expression={ + "$filter": { + "input": {"$map": {"input": "$arr", "in": {"$multiply": ["$$this", 2]}}}, + "cond": {"$gt": ["$$this", 5]}, + } + }, + doc={"arr": [1, 2, 3, 4, 5]}, + expected=[6, 8, 10], + msg="$filter should filter mapped array", + ), + ExpressionTestCase( + "isArray_on_filter_result", + expression={"$isArray": {"$filter": {"input": "$arr", "cond": {"$gt": ["$$this", 0]}}}}, + doc={"arr": [1, 2, 3]}, + expected=True, + msg="$isArray on $filter result should return true", + ), + ExpressionTestCase( + "filter_result_into_reduce", + expression={ + "$reduce": { + "input": {"$filter": {"input": "$arr", "cond": {"$gt": ["$$this", 3]}}}, + "initialValue": 0, + "in": {"$add": ["$$value", "$$this"]}, + } + }, + doc={"arr": [1, 2, 3, 4, 5]}, + expected=9, + msg="$reduce of filtered result (4+5=9)", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(FILTER_COMBINATION_TESTS)) +def test_filter_combination(collection, test): + """Test $filter composed with other operators.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_isArray.py b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_isArray.py new file mode 100644 index 000000000..bb74169da --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_isArray.py @@ -0,0 +1,54 @@ +""" +Combination tests for $isArray composed with other operators. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +ISARRAY_COMBINATION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "isarray_guard_array", + expression={"$cond": {"if": {"$isArray": "$arr"}, "then": {"$size": "$arr"}, "else": "NA"}}, + doc={"arr": [1, 2]}, + expected=2, + msg="$isArray guard should allow $size on array", + ), + ExpressionTestCase( + "isarray_on_concatArrays", + expression={"$isArray": {"$concatArrays": ["$a", "$b"]}}, + doc={"a": [1], "b": [2]}, + expected=True, + msg="$isArray on $concatArrays result should return true", + ), + ExpressionTestCase( + "isarray_on_objectToArray", + expression={"$isArray": {"$objectToArray": "$obj"}}, + doc={"obj": {"a": 1}}, + expected=True, + msg="$isArray on $objectToArray result should return true", + ), + ExpressionTestCase( + "isarray_on_non_array_expression", + expression={"$isArray": {"$add": ["$x", "$y"]}}, + doc={"x": 1, "y": 2}, + expected=False, + msg="$isArray on $add result should return false", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(ISARRAY_COMBINATION_TESTS)) +def test_isArray_combination(collection, test): + """Test $isArray composed with other operators.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) From 73986c5b93ae1c9cedf81af4bc583e397053c7aa Mon Sep 17 00:00:00 2001 From: "Paterson.How" <71473631+PatersonProjects@users.noreply.github.com> Date: Mon, 13 Jul 2026 13:58:27 -0700 Subject: [PATCH 26/35] Add $subtract tests (#677) Signed-off-by: PatersonProjects Signed-off-by: RyanGarfinkel <113050972+RyanGarfinkel@users.noreply.github.com> --- .../arithmetic/subtract/__init__.py | 0 .../subtract/test_subtract_basic.py | 195 ++++++++ .../subtract/test_subtract_boundaries.py | 415 ++++++++++++++++++ .../subtract/test_subtract_bson_types.py | 70 +++ .../subtract/test_subtract_dates.py | 160 +++++++ .../subtract/test_subtract_errors.py | 121 +++++ .../subtract/test_subtract_input_forms.py | 102 +++++ .../subtract/test_subtract_non_finite.py | 185 ++++++++ .../arithmetic/subtract/test_subtract_null.py | 83 ++++ 9 files changed, 1331 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/subtract/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/subtract/test_subtract_basic.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/subtract/test_subtract_boundaries.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/subtract/test_subtract_bson_types.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/subtract/test_subtract_dates.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/subtract/test_subtract_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/subtract/test_subtract_input_forms.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/subtract/test_subtract_non_finite.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/subtract/test_subtract_null.py diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/subtract/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/subtract/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/subtract/test_subtract_basic.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/subtract/test_subtract_basic.py new file mode 100644 index 000000000..2556eef3e --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/subtract/test_subtract_basic.py @@ -0,0 +1,195 @@ +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_TWO_AND_HALF, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_TWO_AND_HALF, + DOUBLE_ZERO, + INT32_ZERO, +) + +# Property [Same-type arithmetic]: $subtract preserves the BSON type when both operands share +# a type. +SAME_TYPE_ARITHMETIC_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "same_type_int32", + doc={"a": 10, "b": 3}, + expression={"$subtract": ["$a", "$b"]}, + expected=7, + msg="$subtract should return int32 for int32 - int32", + ), + ExpressionTestCase( + "same_type_int64", + doc={"a": Int64(20), "b": Int64(5)}, + expression={"$subtract": ["$a", "$b"]}, + expected=Int64(15), + msg="$subtract should return int64 for int64 - int64", + ), + ExpressionTestCase( + "same_type_double", + doc={"a": 10.5, "b": DOUBLE_TWO_AND_HALF}, + expression={"$subtract": ["$a", "$b"]}, + expected=8.0, + msg="$subtract should return double for double - double", + ), + ExpressionTestCase( + "same_type_decimal", + doc={"a": Decimal128("20.5"), "b": Decimal128("10.5")}, + expression={"$subtract": ["$a", "$b"]}, + expected=Decimal128("10.0"), + msg="$subtract should return Decimal128 for Decimal128 - Decimal128", + ), +] + +# Property [Mixed-type promotion]: $subtract promotes the result to the wider of the two input +# types. +MIXED_TYPE_PROMOTION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int32_int64", + doc={"a": 10, "b": Int64(3)}, + expression={"$subtract": ["$a", "$b"]}, + expected=Int64(7), + msg="$subtract should promote to int64 for int32 - int64", + ), + ExpressionTestCase( + "int32_double", + doc={"a": 10, "b": DOUBLE_TWO_AND_HALF}, + expression={"$subtract": ["$a", "$b"]}, + expected=7.5, + msg="$subtract should promote to double for int32 - double", + ), + ExpressionTestCase( + "int32_decimal", + doc={"a": 10, "b": DECIMAL128_TWO_AND_HALF}, + expression={"$subtract": ["$a", "$b"]}, + expected=Decimal128("7.5"), + msg="$subtract should promote to Decimal128 for int32 - Decimal128", + ), + ExpressionTestCase( + "int64_double", + doc={"a": Int64(20), "b": 5.5}, + expression={"$subtract": ["$a", "$b"]}, + expected=14.5, + msg="$subtract should promote to double for int64 - double", + ), + ExpressionTestCase( + "int64_decimal", + doc={"a": Int64(20), "b": Decimal128("5.5")}, + expression={"$subtract": ["$a", "$b"]}, + expected=Decimal128("14.5"), + msg="$subtract should promote to Decimal128 for int64 - Decimal128", + ), + ExpressionTestCase( + "double_decimal", + doc={"a": 10.5, "b": DECIMAL128_TWO_AND_HALF}, + expression={"$subtract": ["$a", "$b"]}, + expected=Decimal128("8.0000000000000"), + msg="$subtract should promote to Decimal128 for double - Decimal128", + ), +] + +# Property [Sign handling]: $subtract correctly computes the sign of the result. +SIGN_HANDLING_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "negative_positive", + doc={"a": -10, "b": 3}, + expression={"$subtract": ["$a", "$b"]}, + expected=-13, + msg="$subtract should return negative for negative minuend minus positive subtrahend", + ), + ExpressionTestCase( + "positive_negative", + doc={"a": 10, "b": -3}, + expression={"$subtract": ["$a", "$b"]}, + expected=13, + msg="$subtract should return positive for positive minuend minus negative subtrahend", + ), + ExpressionTestCase( + "both_negative", + doc={"a": -10, "b": -3}, + expression={"$subtract": ["$a", "$b"]}, + expected=-7, + msg="$subtract should return negative when both operands are negative and |a| > |b|", + ), + ExpressionTestCase( + "result_negative", + doc={"a": 5, "b": 10}, + expression={"$subtract": ["$a", "$b"]}, + expected=-5, + msg="$subtract should return negative when the minuend is smaller than the subtrahend", + ), + ExpressionTestCase( + "result_negative_double", + doc={"a": 5.5, "b": 10}, + expression={"$subtract": ["$a", "$b"]}, + expected=-4.5, + msg="$subtract should return negative double when minuend is smaller than subtrahend", + ), +] + +# Property [Zero handling]: $subtract handles zero operands correctly. +ZERO_HANDLING_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "zero_minuend", + doc={"a": INT32_ZERO, "b": 5}, + expression={"$subtract": ["$a", "$b"]}, + expected=-5, + msg="$subtract should return negated subtrahend when the minuend is zero", + ), + ExpressionTestCase( + "zero_subtrahend", + doc={"a": 5, "b": INT32_ZERO}, + expression={"$subtract": ["$a", "$b"]}, + expected=5, + msg="$subtract should return the minuend unchanged when the subtrahend is zero", + ), + ExpressionTestCase( + "zeros", + doc={"a": INT32_ZERO, "b": INT32_ZERO}, + expression={"$subtract": ["$a", "$b"]}, + expected=INT32_ZERO, + msg="$subtract should return zero for zero - zero", + ), + ExpressionTestCase( + "zero_negative_zero", + doc={"a": INT32_ZERO, "b": DOUBLE_NEGATIVE_ZERO}, + expression={"$subtract": ["$a", "$b"]}, + expected=DOUBLE_ZERO, + msg="$subtract of int32 zero minus double negative-zero should return double positive-zero", + ), + ExpressionTestCase( + "negative_zero_zero", + doc={"a": DOUBLE_NEGATIVE_ZERO, "b": INT32_ZERO}, + expression={"$subtract": ["$a", "$b"]}, + expected=DOUBLE_NEGATIVE_ZERO, + msg="$subtract of double negative-zero minus int32 zero should return double negative-zero", + ), +] + +SUBTRACT_BASIC_TESTS: list[ExpressionTestCase] = ( + SAME_TYPE_ARITHMETIC_TESTS + + MIXED_TYPE_PROMOTION_TESTS + + SIGN_HANDLING_TESTS + + ZERO_HANDLING_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(SUBTRACT_BASIC_TESTS)) +def test_subtract_basic(collection, test_case: ExpressionTestCase): + """Test $subtract same-type and mixed-type numeric arithmetic.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/subtract/test_subtract_boundaries.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/subtract/test_subtract_boundaries.py new file mode 100644 index 000000000..405e382e6 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/subtract/test_subtract_boundaries.py @@ -0,0 +1,415 @@ +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_HALF, + DECIMAL128_JUST_ABOVE_HALF, + DECIMAL128_JUST_BELOW_HALF, + DECIMAL128_LARGE_EXPONENT, + DECIMAL128_MANY_TRAILING_ZEROS, + DECIMAL128_MAX, + DECIMAL128_MIN, + DECIMAL128_NEGATIVE_HALF, + DECIMAL128_NEGATIVE_ONE_AND_HALF, + DECIMAL128_ONE_AND_HALF, + DECIMAL128_SMALL_EXPONENT, + DECIMAL128_TRAILING_ZERO, + DECIMAL128_TWO_AND_HALF, + DECIMAL128_ZERO, + DOUBLE_FROM_INT64_MAX, + DOUBLE_HALF, + DOUBLE_JUST_ABOVE_HALF, + DOUBLE_JUST_BELOW_HALF, + DOUBLE_MAX_SAFE_INTEGER, + DOUBLE_MIN_SUBNORMAL, + DOUBLE_NEAR_MAX, + DOUBLE_NEAR_MIN, + DOUBLE_NEGATIVE_HALF, + DOUBLE_NEGATIVE_ONE_AND_HALF, + DOUBLE_ONE_AND_HALF, + DOUBLE_TWO_AND_HALF, + FLOAT_INFINITY, + FLOAT_NEGATIVE_INFINITY, + INT32_MAX, + INT32_MAX_MINUS_1, + INT32_MIN, + INT32_MIN_PLUS_1, + INT32_OVERFLOW, + INT32_UNDERFLOW, + INT32_ZERO, + INT64_MAX, + INT64_MAX_MINUS_1, + INT64_MIN, + INT64_MIN_PLUS_1, +) + +# Property [Int32 overflow]: $subtract promotes int32 results to int64 on overflow/underflow. +INT32_OVERFLOW_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int32_overflow", + doc={"a": INT32_MAX, "b": -1}, + expression={"$subtract": ["$a", "$b"]}, + expected=Int64(INT32_OVERFLOW), + msg="$subtract should promote to int64 when the int32 result exceeds INT32_MAX", + ), + ExpressionTestCase( + "int32_underflow", + doc={"a": INT32_MIN, "b": 1}, + expression={"$subtract": ["$a", "$b"]}, + expected=Int64(INT32_UNDERFLOW), + msg="$subtract should promote to int64 when the int32 result is below INT32_MIN", + ), + # Int32 boundary values + ExpressionTestCase( + "int32_max_minuend", + doc={"a": INT32_MAX, "b": 10}, + expression={"$subtract": ["$a", "$b"]}, + expected=2147483637, + msg="$subtract should correctly subtract from INT32_MAX", + ), + ExpressionTestCase( + "int32_max_minus_1_minuend", + doc={"a": INT32_MAX_MINUS_1, "b": 10}, + expression={"$subtract": ["$a", "$b"]}, + expected=2147483636, + msg="$subtract should correctly subtract from INT32_MAX - 1", + ), + ExpressionTestCase( + "int32_min_minuend", + doc={"a": INT32_MIN, "b": 10}, + expression={"$subtract": ["$a", "$b"]}, + expected=Int64(-2147483658), + msg="$subtract should promote to int64 when subtracting from INT32_MIN", + ), + ExpressionTestCase( + "int32_min_plus_1_minuend", + doc={"a": INT32_MIN_PLUS_1, "b": 10}, + expression={"$subtract": ["$a", "$b"]}, + expected=Int64(-2147483657), + msg="$subtract should promote to int64 when subtracting from INT32_MIN + 1", + ), + ExpressionTestCase( + "int32_max_subtrahend", + doc={"a": 10, "b": INT32_MAX}, + expression={"$subtract": ["$a", "$b"]}, + expected=-2147483637, + msg="$subtract should correctly subtract INT32_MAX as the subtrahend", + ), + ExpressionTestCase( + "int32_min_subtrahend", + doc={"a": 10, "b": INT32_MIN}, + expression={"$subtract": ["$a", "$b"]}, + expected=Int64(2147483658), + msg="$subtract should promote to int64 when INT32_MIN is the subtrahend", + ), +] + +# Property [Int64 overflow]: $subtract promotes int64 results to double on overflow/underflow. +INT64_OVERFLOW_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int64_overflow", + doc={"a": INT64_MAX, "b": -1}, + expression={"$subtract": ["$a", "$b"]}, + expected=DOUBLE_FROM_INT64_MAX, + msg="$subtract should promote to double when the int64 result exceeds INT64_MAX", + ), + ExpressionTestCase( + "int64_underflow", + doc={"a": INT64_MIN, "b": 1}, + expression={"$subtract": ["$a", "$b"]}, + expected=-DOUBLE_FROM_INT64_MAX, + msg="$subtract should promote to double when the int64 result is below INT64_MIN", + ), + # Int64 boundary values + ExpressionTestCase( + "int64_max_minuend", + doc={"a": INT64_MAX, "b": Int64(10)}, + expression={"$subtract": ["$a", "$b"]}, + expected=Int64(9223372036854775797), + msg="$subtract should correctly subtract from INT64_MAX", + ), + ExpressionTestCase( + "int64_max_minus_1_minuend", + doc={"a": INT64_MAX_MINUS_1, "b": Int64(10)}, + expression={"$subtract": ["$a", "$b"]}, + expected=Int64(9223372036854775796), + msg="$subtract should correctly subtract from INT64_MAX - 1", + ), + ExpressionTestCase( + "int64_min_minuend", + doc={"a": INT64_MIN, "b": Int64(10)}, + expression={"$subtract": ["$a", "$b"]}, + expected=-DOUBLE_FROM_INT64_MAX, + msg="$subtract should overflow to double when subtracting from INT64_MIN", + ), + ExpressionTestCase( + "int64_min_plus_1_minuend", + doc={"a": INT64_MIN_PLUS_1, "b": Int64(10)}, + expression={"$subtract": ["$a", "$b"]}, + expected=-DOUBLE_FROM_INT64_MAX, + msg="$subtract should overflow to double when subtracting from INT64_MIN + 1", + ), + ExpressionTestCase( + "int64_max_subtrahend", + doc={"a": Int64(10), "b": INT64_MAX}, + expression={"$subtract": ["$a", "$b"]}, + expected=Int64(-9223372036854775797), + msg="$subtract should correctly subtract INT64_MAX as the subtrahend", + ), + ExpressionTestCase( + "int64_min_subtrahend", + doc={"a": Int64(10), "b": INT64_MIN}, + expression={"$subtract": ["$a", "$b"]}, + expected=DOUBLE_FROM_INT64_MAX, + msg="$subtract should overflow to double when INT64_MIN is the subtrahend", + ), +] + +# Property [Double overflow]: $subtract returns ±Infinity on double overflow. +DOUBLE_OVERFLOW_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "double_overflow", + doc={"a": DOUBLE_NEAR_MAX, "b": -DOUBLE_NEAR_MAX}, + expression={"$subtract": ["$a", "$b"]}, + expected=FLOAT_INFINITY, + msg="$subtract should return Infinity on double overflow", + ), + ExpressionTestCase( + "double_underflow", + doc={"a": -DOUBLE_NEAR_MAX, "b": DOUBLE_NEAR_MAX}, + expression={"$subtract": ["$a", "$b"]}, + expected=FLOAT_NEGATIVE_INFINITY, + msg="$subtract should return -Infinity on double underflow", + ), + # Double boundary values + ExpressionTestCase( + "double_min_subnormal_minuend", + doc={"a": DOUBLE_MIN_SUBNORMAL, "b": INT32_ZERO}, + expression={"$subtract": ["$a", "$b"]}, + expected=DOUBLE_MIN_SUBNORMAL, + msg="$subtract should handle the minimum subnormal double value", + ), + ExpressionTestCase( + "double_near_min_minuend", + doc={"a": DOUBLE_NEAR_MIN, "b": INT32_ZERO}, + expression={"$subtract": ["$a", "$b"]}, + expected=DOUBLE_NEAR_MIN, + msg="$subtract should handle doubles near the minimum normal value", + ), + ExpressionTestCase( + "double_near_max_minuend", + doc={"a": DOUBLE_NEAR_MAX, "b": 1e307}, + expression={"$subtract": ["$a", "$b"]}, + expected=9e307, + msg="$subtract should handle doubles near the maximum value", + ), + ExpressionTestCase( + "double_max_safe_integer_minuend", + doc={"a": float(DOUBLE_MAX_SAFE_INTEGER), "b": 1}, + expression={"$subtract": ["$a", "$b"]}, + expected=9007199254740991.0, + msg="$subtract should handle the maximum safe integer double as the minuend", + ), + ExpressionTestCase( + "double_max_safe_integer_subtrahend", + doc={"a": 1, "b": float(DOUBLE_MAX_SAFE_INTEGER)}, + expression={"$subtract": ["$a", "$b"]}, + expected=-9007199254740991.0, + msg="$subtract should handle the maximum safe integer double as the subtrahend", + ), +] + +# Property [Decimal128 precision]: $subtract preserves Decimal128 full precision. +DECIMAL128_PRECISION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "decimal128_max_minuend", + doc={"a": DECIMAL128_MAX, "b": Decimal128("1")}, + expression={"$subtract": ["$a", "$b"]}, + expected=DECIMAL128_MAX, + msg="$subtract should handle DECIMAL128_MAX as the minuend", + ), + ExpressionTestCase( + "decimal128_min_minuend", + doc={"a": DECIMAL128_MIN, "b": Decimal128("1")}, + expression={"$subtract": ["$a", "$b"]}, + expected=DECIMAL128_MIN, + msg="$subtract should handle DECIMAL128_MIN as the minuend", + ), + ExpressionTestCase( + "decimal128_small_exponent_minuend", + doc={"a": DECIMAL128_SMALL_EXPONENT, "b": DECIMAL128_ZERO}, + expression={"$subtract": ["$a", "$b"]}, + expected=DECIMAL128_SMALL_EXPONENT, + msg="$subtract should handle Decimal128 with a small exponent", + ), + ExpressionTestCase( + "decimal128_large_exponent_minuend", + doc={"a": DECIMAL128_LARGE_EXPONENT, "b": Decimal128("1")}, + expression={"$subtract": ["$a", "$b"]}, + expected=DECIMAL128_LARGE_EXPONENT, + msg="$subtract should handle Decimal128 with a large exponent", + ), + ExpressionTestCase( + "decimal128_trailing_zero", + doc={"a": DECIMAL128_TRAILING_ZERO, "b": DECIMAL128_HALF}, + expression={"$subtract": ["$a", "$b"]}, + expected=DECIMAL128_HALF, + msg="$subtract should handle Decimal128 with a trailing zero", + ), + ExpressionTestCase( + "decimal128_many_trailing_zeros", + doc={"a": DECIMAL128_MANY_TRAILING_ZEROS, "b": DECIMAL128_HALF}, + expression={"$subtract": ["$a", "$b"]}, + expected=Decimal128("0.50000000000000000000000000000000"), + msg="$subtract should handle Decimal128 with many trailing zeros", + ), + # Decimal128 precision + ExpressionTestCase( + "decimal_precision", + doc={"a": Decimal128("10.5"), "b": DECIMAL128_TWO_AND_HALF}, + expression={"$subtract": ["$a", "$b"]}, + expected=Decimal128("8.0"), + msg="$subtract should preserve Decimal128 precision", + ), + ExpressionTestCase( + "decimal_precision_small", + doc={"a": Decimal128("0.3"), "b": Decimal128("0.1")}, + expression={"$subtract": ["$a", "$b"]}, + expected=Decimal128("0.2"), + msg="$subtract should compute 0.3 - 0.1 exactly in Decimal128 without floating-point error", + ), + ExpressionTestCase( + "decimal_large_precision", + doc={ + "a": Decimal128("1000000000000000000000000000000000"), + "b": Decimal128("1"), + }, + expression={"$subtract": ["$a", "$b"]}, + expected=Decimal128("999999999999999999999999999999999"), + msg="$subtract should handle large Decimal128 values with full precision", + ), + # Double rounding edge cases + ExpressionTestCase( + "double_half_minuend", + doc={"a": DOUBLE_HALF, "b": 0.25}, + expression={"$subtract": ["$a", "$b"]}, + expected=0.25, + msg="$subtract should correctly compute 0.5 - 0.25 = 0.25", + ), + ExpressionTestCase( + "double_one_and_half_minuend", + doc={"a": DOUBLE_ONE_AND_HALF, "b": DOUBLE_HALF}, + expression={"$subtract": ["$a", "$b"]}, + expected=1.0, + msg="$subtract should correctly compute 1.5 - 0.5 = 1.0", + ), + ExpressionTestCase( + "double_two_and_half_minuend", + doc={"a": DOUBLE_TWO_AND_HALF, "b": DOUBLE_ONE_AND_HALF}, + expression={"$subtract": ["$a", "$b"]}, + expected=1.0, + msg="$subtract should correctly compute 2.5 - 1.5 = 1.0", + ), + ExpressionTestCase( + "double_negative_half_minuend", + doc={"a": DOUBLE_NEGATIVE_HALF, "b": DOUBLE_HALF}, + expression={"$subtract": ["$a", "$b"]}, + expected=-1.0, + msg="$subtract should correctly compute -0.5 - 0.5 = -1.0", + ), + ExpressionTestCase( + "double_negative_one_and_half_minuend", + doc={"a": DOUBLE_NEGATIVE_ONE_AND_HALF, "b": DOUBLE_HALF}, + expression={"$subtract": ["$a", "$b"]}, + expected=-2.0, + msg="$subtract should correctly compute -1.5 - 0.5 = -2.0", + ), + ExpressionTestCase( + "double_just_below_half_minuend", + doc={"a": DOUBLE_JUST_BELOW_HALF, "b": 0.25}, + expression={"$subtract": ["$a", "$b"]}, + expected=pytest.approx(0.2499999999999994), + msg="$subtract should handle a double just below 0.5", + ), + ExpressionTestCase( + "double_just_above_half_minuend", + doc={"a": DOUBLE_JUST_ABOVE_HALF, "b": 0.25}, + expression={"$subtract": ["$a", "$b"]}, + expected=pytest.approx(0.250000001), + msg="$subtract should handle a double just above 0.5", + ), + # Decimal128 rounding edge cases + ExpressionTestCase( + "decimal_half_minuend", + doc={"a": DECIMAL128_HALF, "b": Decimal128("0.25")}, + expression={"$subtract": ["$a", "$b"]}, + expected=Decimal128("0.25"), + msg="$subtract should correctly compute Decimal128 0.5 - 0.25 = 0.25", + ), + ExpressionTestCase( + "decimal_one_and_half_minuend", + doc={"a": DECIMAL128_ONE_AND_HALF, "b": DECIMAL128_HALF}, + expression={"$subtract": ["$a", "$b"]}, + expected=DECIMAL128_TRAILING_ZERO, + msg="$subtract should correctly compute Decimal128 1.5 - 0.5 = 1.0", + ), + ExpressionTestCase( + "decimal_two_and_half_minuend", + doc={"a": DECIMAL128_TWO_AND_HALF, "b": DECIMAL128_ONE_AND_HALF}, + expression={"$subtract": ["$a", "$b"]}, + expected=DECIMAL128_TRAILING_ZERO, + msg="$subtract should correctly compute Decimal128 2.5 - 1.5 = 1.0", + ), + ExpressionTestCase( + "decimal_negative_half_minuend", + doc={"a": DECIMAL128_NEGATIVE_HALF, "b": DECIMAL128_HALF}, + expression={"$subtract": ["$a", "$b"]}, + expected=Decimal128("-1.0"), + msg="$subtract should correctly compute Decimal128 -0.5 - 0.5 = -1.0", + ), + ExpressionTestCase( + "decimal_negative_one_and_half_minuend", + doc={"a": DECIMAL128_NEGATIVE_ONE_AND_HALF, "b": DECIMAL128_HALF}, + expression={"$subtract": ["$a", "$b"]}, + expected=Decimal128("-2.0"), + msg="$subtract should correctly compute Decimal128 -1.5 - 0.5 = -2.0", + ), + ExpressionTestCase( + "decimal_just_below_half_minuend", + doc={"a": DECIMAL128_JUST_BELOW_HALF, "b": Decimal128("0.25")}, + expression={"$subtract": ["$a", "$b"]}, + expected=Decimal128("0.2499999999999999999999999999999999"), + msg="$subtract should handle Decimal128 just below 0.5", + ), + ExpressionTestCase( + "decimal_just_above_half_minuend", + doc={"a": DECIMAL128_JUST_ABOVE_HALF, "b": Decimal128("0.25")}, + expression={"$subtract": ["$a", "$b"]}, + expected=Decimal128("0.2500000000000000000000000000000001"), + msg="$subtract should handle Decimal128 just above 0.5", + ), +] + +SUBTRACT_BOUNDARY_TESTS: list[ExpressionTestCase] = ( + INT32_OVERFLOW_TESTS + INT64_OVERFLOW_TESTS + DOUBLE_OVERFLOW_TESTS + DECIMAL128_PRECISION_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(SUBTRACT_BOUNDARY_TESTS)) +def test_subtract_boundaries(collection, test_case: ExpressionTestCase): + """Test $subtract boundary values, overflow behavior, and numeric precision.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/subtract/test_subtract_bson_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/subtract/test_subtract_bson_types.py new file mode 100644 index 000000000..a29653bdb --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/subtract/test_subtract_bson_types.py @@ -0,0 +1,70 @@ +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.assertions import assertNotError +from documentdb_tests.framework.bson_type_validator import ( + BsonType, + BsonTypeTestCase, + generate_bson_acceptance_test_cases, + generate_bson_rejection_test_cases, +) + +# Property [Minuend type acceptance]: $subtract accepts numeric and date types as minuend. +# Property [Minuend type rejection]: $subtract rejects all other BSON types as minuend. +SUBTRACT_MINUEND_SPEC = BsonTypeTestCase( + id="subtract_minuend", + msg="$subtract minuend type", + valid_types=[ + BsonType.DOUBLE, + BsonType.INT, + BsonType.LONG, + BsonType.DECIMAL, + BsonType.DATE, + BsonType.NULL, + ], +) + +# Property [Subtrahend type acceptance]: $subtract accepts numeric types as subtrahend. +# Property [Subtrahend type rejection]: $subtract rejects all other BSON types as subtrahend, +# including Date when the minuend is numeric. +SUBTRACT_SUBTRAHEND_SPEC = BsonTypeTestCase( + id="subtract_subtrahend", + msg="$subtract subtrahend type", + valid_types=[BsonType.DOUBLE, BsonType.INT, BsonType.LONG, BsonType.DECIMAL, BsonType.NULL], +) + +MINUEND_REJECTION_CASES = generate_bson_rejection_test_cases([SUBTRACT_MINUEND_SPEC]) +MINUEND_ACCEPTANCE_CASES = generate_bson_acceptance_test_cases([SUBTRACT_MINUEND_SPEC]) +SUBTRAHEND_REJECTION_CASES = generate_bson_rejection_test_cases([SUBTRACT_SUBTRAHEND_SPEC]) +SUBTRAHEND_ACCEPTANCE_CASES = generate_bson_acceptance_test_cases([SUBTRACT_SUBTRAHEND_SPEC]) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", MINUEND_REJECTION_CASES) +def test_subtract_rejects_invalid_minuend(collection, bson_type, sample_value, spec): + """Test $subtract rejects invalid BSON types as the minuend.""" + result = execute_expression_with_insert(collection, {"$subtract": [sample_value, 1]}, {}) + assert_expression_result(result, error_code=spec.expected_code(bson_type)) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", MINUEND_ACCEPTANCE_CASES) +def test_subtract_accepts_valid_minuend(collection, bson_type, sample_value, spec): + """Test $subtract accepts valid BSON types (numeric and date) as the minuend.""" + result = execute_expression_with_insert(collection, {"$subtract": [sample_value, 1]}, {}) + assertNotError(result, msg=f"{spec.msg} should accept {bson_type.value}") + + +@pytest.mark.parametrize("bson_type,sample_value,spec", SUBTRAHEND_REJECTION_CASES) +def test_subtract_rejects_invalid_subtrahend(collection, bson_type, sample_value, spec): + """Test $subtract rejects invalid BSON types as the subtrahend.""" + result = execute_expression_with_insert(collection, {"$subtract": [10, sample_value]}, {}) + assert_expression_result(result, error_code=spec.expected_code(bson_type)) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", SUBTRAHEND_ACCEPTANCE_CASES) +def test_subtract_accepts_valid_subtrahend(collection, bson_type, sample_value, spec): + """Test $subtract accepts valid BSON types (numeric) as the subtrahend.""" + result = execute_expression_with_insert(collection, {"$subtract": [10, sample_value]}, {}) + assertNotError(result, msg=f"{spec.msg} should accept {bson_type.value}") diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/subtract/test_subtract_dates.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/subtract/test_subtract_dates.py new file mode 100644 index 000000000..0a577fe7e --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/subtract/test_subtract_dates.py @@ -0,0 +1,160 @@ +from datetime import datetime, timezone + +import pytest +from bson import Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_ONE_AND_HALF, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_TWO_AND_HALF, + INT32_ZERO, +) + +# Property [Date - numeric]: $subtract returns a date when the minuend is a date and subtrahend +# is numeric (milliseconds). +DATE_NUMERIC_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "date_int32", + doc={ + "a": datetime(2026, 1, 2, 0, 0, 0, tzinfo=timezone.utc), + "b": 86400000, + }, + expression={"$subtract": ["$a", "$b"]}, + expected=datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$subtract should subtract int32 milliseconds from a date", + ), + ExpressionTestCase( + "date_int64", + doc={ + "a": datetime(2026, 1, 2, 0, 0, 0, tzinfo=timezone.utc), + "b": Int64(86400000), + }, + expression={"$subtract": ["$a", "$b"]}, + expected=datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$subtract should subtract int64 milliseconds from a date", + ), + # Date minus negative number (moves date forward) + ExpressionTestCase( + "date_negative", + doc={ + "a": datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + "b": -86400000, + }, + expression={"$subtract": ["$a", "$b"]}, + expected=datetime(2026, 1, 2, 0, 0, 0, tzinfo=timezone.utc), + msg="$subtract should move a date forward when subtracting a negative millisecond offset", + ), + ExpressionTestCase( + "date_zero", + doc={ + "a": datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + "b": INT32_ZERO, + }, + expression={"$subtract": ["$a", "$b"]}, + expected=datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$subtract should return the same date when subtracting zero milliseconds", + ), + ExpressionTestCase( + "date_negative_zero", + doc={ + "a": datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + "b": DOUBLE_NEGATIVE_ZERO, + }, + expression={"$subtract": ["$a", "$b"]}, + expected=datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$subtract should return the same date when subtracting negative-zero milliseconds", + ), +] + +# Property [Date - date]: $subtract returns the difference in milliseconds when both operands +# are dates. +DATE_DATE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "two_dates", + doc={ + "a": datetime(2026, 1, 2, 0, 0, 0, tzinfo=timezone.utc), + "b": datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + }, + expression={"$subtract": ["$a", "$b"]}, + expected=Int64(86400000), + msg="$subtract of two dates should return the difference in milliseconds", + ), + ExpressionTestCase( + "two_dates_negative", + doc={ + "a": datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + "b": datetime(2026, 1, 2, 0, 0, 0, tzinfo=timezone.utc), + }, + expression={"$subtract": ["$a", "$b"]}, + expected=Int64(-86400000), + msg="$subtract of two dates should return negative ms when the minuend date is earlier", + ), +] + +# Property [Date rounding]: fractional ms operands are rounded. +DATE_ROUNDING_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "date_decimal", + doc={ + "a": datetime(2026, 1, 1, 0, 0, 1, tzinfo=timezone.utc), + "b": DECIMAL128_ONE_AND_HALF, + }, + expression={"$subtract": ["$a", "$b"]}, + expected=datetime(2026, 1, 1, 0, 0, 0, 998000, tzinfo=timezone.utc), + msg="$subtract should round Decimal128 1.5 ms to 2 ms before subtracting", + ), + ExpressionTestCase( + "date_double_round_up", + doc={ + "a": datetime(2026, 1, 1, 0, 0, 0, 3000, tzinfo=timezone.utc), + "b": DOUBLE_TWO_AND_HALF, + }, + expression={"$subtract": ["$a", "$b"]}, + expected=datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$subtract should round double 2.5 ms up to 3 ms before subtracting", + ), + ExpressionTestCase( + "date_double_round_down_negative", + doc={ + "a": datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + "b": -2.5, + }, + expression={"$subtract": ["$a", "$b"]}, + expected=datetime(2026, 1, 1, 0, 0, 0, 3000, tzinfo=timezone.utc), + msg="$subtract should round double -2.5 ms down to -3 ms before subtracting", + ), + ExpressionTestCase( + "date_double_truncates", + doc={ + "a": datetime(2026, 1, 1, 0, 0, 0, 5000, tzinfo=timezone.utc), + "b": 4.4, + }, + expression={"$subtract": ["$a", "$b"]}, + expected=datetime(2026, 1, 1, 0, 0, 0, 1000, tzinfo=timezone.utc), + msg="$subtract should round double 4.4 ms to 4 ms before subtracting from a date", + ), +] + +SUBTRACT_DATE_TESTS: list[ExpressionTestCase] = ( + DATE_NUMERIC_TESTS + DATE_DATE_TESTS + DATE_ROUNDING_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(SUBTRACT_DATE_TESTS)) +def test_subtract_dates(collection, test_case: ExpressionTestCase): + """Test $subtract date arithmetic: date minus numeric and date minus date.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/subtract/test_subtract_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/subtract/test_subtract_errors.py new file mode 100644 index 000000000..23f44658d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/subtract/test_subtract_errors.py @@ -0,0 +1,121 @@ +from datetime import datetime, timezone + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + EXPRESSION_TYPE_MISMATCH_ERROR, + OVERFLOW_ERROR, + TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_NAN, + FLOAT_INFINITY, + FLOAT_NAN, +) + +# Property [Date constraint]: $subtract enforces date arithmetic type rules. +DATE_CONSTRAINT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "number_minus_date", + doc={"a": 1, "b": datetime(2026, 1, 1, tzinfo=timezone.utc)}, + expression={"$subtract": ["$a", "$b"]}, + error_code=TYPE_MISMATCH_ERROR, + msg="$subtract should reject a date subtrahend when the minuend is a number", + ), + ExpressionTestCase( + "date_minus_string", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": "string"}, + expression={"$subtract": ["$a", "$b"]}, + error_code=TYPE_MISMATCH_ERROR, + msg="$subtract should reject a string subtrahend when the minuend is a date", + ), + ExpressionTestCase( + "date_minus_bool", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": True}, + expression={"$subtract": ["$a", "$b"]}, + error_code=TYPE_MISMATCH_ERROR, + msg="$subtract should reject a boolean subtrahend when the minuend is a date", + ), + ExpressionTestCase( + "date_minus_array", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": [1, 2]}, + expression={"$subtract": ["$a", "$b"]}, + error_code=TYPE_MISMATCH_ERROR, + msg="$subtract should reject an array subtrahend when the minuend is a date", + ), +] + +# Property [Date NaN/Inf]: $subtract rejects NaN/Infinity as a date offset with +# TYPE_MISMATCH_DATE_ERROR. +DATE_NAN_INF_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "date_minus_nan", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": FLOAT_NAN}, + expression={"$subtract": ["$a", "$b"]}, + error_code=OVERFLOW_ERROR, + msg="$subtract should reject NaN as a date millisecond offset", + ), + ExpressionTestCase( + "date_minus_infinity", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": FLOAT_INFINITY}, + expression={"$subtract": ["$a", "$b"]}, + error_code=OVERFLOW_ERROR, + msg="$subtract should reject Infinity as a date millisecond offset", + ), + ExpressionTestCase( + "date_minus_decimal_nan", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": DECIMAL128_NAN}, + expression={"$subtract": ["$a", "$b"]}, + error_code=OVERFLOW_ERROR, + msg="$subtract should reject Decimal128 NaN as a date millisecond offset", + ), +] + +# Property [Arity]: $subtract requires exactly two arguments. +ARITY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "zero_args", + doc={}, + expression={"$subtract": []}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="$subtract should reject an empty argument list", + ), + ExpressionTestCase( + "one_arg", + doc={}, + expression={"$subtract": [1]}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="$subtract should reject a single argument", + ), + ExpressionTestCase( + "three_args", + doc={}, + expression={"$subtract": [1, 2, 3]}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="$subtract should reject three arguments", + ), +] + +SUBTRACT_ERROR_TESTS: list[ExpressionTestCase] = ( + DATE_CONSTRAINT_TESTS + DATE_NAN_INF_TESTS + ARITY_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(SUBTRACT_ERROR_TESTS)) +def test_subtract_errors(collection, test_case: ExpressionTestCase): + """Test $subtract error handling for invalid types, date constraints, and arity violations.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/subtract/test_subtract_input_forms.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/subtract/test_subtract_input_forms.py new file mode 100644 index 000000000..dbb9d5c27 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/subtract/test_subtract_input_forms.py @@ -0,0 +1,102 @@ +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import EXPRESSION_TYPE_MISMATCH_ERROR +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import INT32_ZERO + +# Property [Nested expressions]: $subtract accepts other expressions as operands. +NESTED_EXPRESSION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_subtract_2", + doc={}, + expression={"$subtract": [{"$subtract": [10, 3]}, 2]}, + expected=5, + msg="$subtract should accept a nested $subtract expression as the minuend", + ), + ExpressionTestCase( + "nested_subtract_3", + doc={}, + expression={"$subtract": [{"$subtract": [{"$subtract": [100, 10]}, 20]}, 30]}, + expected=40, + msg="$subtract should support three levels of nested $subtract expressions", + ), +] + +# Property [Field path lookups]: $subtract resolves nested and deeply-nested field paths. +FIELD_PATH_LOOKUP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_field", + doc={"a": {"b": 10}, "c": 3}, + expression={"$subtract": ["$a.b", "$c"]}, + expected=7, + msg="$subtract should resolve a nested field path such as $a.b", + ), + ExpressionTestCase( + "nonexistent_field", + doc={"a": {"missing": 1}, "b": 5}, + expression={"$subtract": ["$a.nonexistent", "$b"]}, + expected=None, + msg="$subtract should return null when a field path resolves to a missing field", + ), + ExpressionTestCase( + "deeply_nested_field", + doc={"a": {"b": {"c": {"d": 20}}}}, + expression={"$subtract": ["$a.b.c.d", 8]}, + expected=12, + msg="$subtract should resolve a deeply nested field path", + ), +] + +# Property [Array element access]: $subtract works with $arrayElemAt expressions as operands. +ARRAY_ELEMENT_ACCESS_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "array_index", + doc={"arr": [10, 5, 2]}, + expression={ + "$subtract": [ + {"$arrayElemAt": ["$arr", INT32_ZERO]}, + {"$arrayElemAt": ["$arr", 1]}, + ] + }, + expected=5, + msg="$subtract should support $arrayElemAt expressions as operands", + ), +] + +# Property [Composite array rejection]: $subtract rejects a composite array from $x.y on +# an array-of-objects. +COMPOSITE_ARRAY_REJECTION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "composite_array_field", + doc={"x": [{"y": 10}, {"y": 3}]}, + expression={"$subtract": "$x.y"}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="$subtract should reject a composite array produced by $x.y on an array-of-objects", + ), +] + +SUBTRACT_INPUT_FORM_TESTS: list[ExpressionTestCase] = ( + NESTED_EXPRESSION_TESTS + + FIELD_PATH_LOOKUP_TESTS + + ARRAY_ELEMENT_ACCESS_TESTS + + COMPOSITE_ARRAY_REJECTION_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(SUBTRACT_INPUT_FORM_TESTS)) +def test_subtract_input_forms(collection, test_case: ExpressionTestCase): + """Test $subtract with various expression input forms and field path lookups.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/subtract/test_subtract_non_finite.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/subtract/test_subtract_non_finite.py new file mode 100644 index 000000000..987660216 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/subtract/test_subtract_non_finite.py @@ -0,0 +1,185 @@ +import math + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DOUBLE_ONE_AND_HALF, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + INT32_ZERO, +) + +# Property [Infinity propagation]: $subtract propagates Infinity when one operand is infinite. +INFINITY_PROPAGATION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "infinity_minuend", + doc={"a": FLOAT_INFINITY, "b": 1}, + expression={"$subtract": ["$a", "$b"]}, + expected=FLOAT_INFINITY, + msg="$subtract of Infinity minus a number should return Infinity", + ), + ExpressionTestCase( + "negative_infinity_minuend", + doc={"a": FLOAT_NEGATIVE_INFINITY, "b": 1}, + expression={"$subtract": ["$a", "$b"]}, + expected=FLOAT_NEGATIVE_INFINITY, + msg="$subtract of -Infinity minus a number should return -Infinity", + ), + ExpressionTestCase( + "infinity_subtrahend", + doc={"a": 1, "b": FLOAT_INFINITY}, + expression={"$subtract": ["$a", "$b"]}, + expected=FLOAT_NEGATIVE_INFINITY, + msg="$subtract of a number minus Infinity should return -Infinity", + ), + ExpressionTestCase( + "negative_infinity_subtrahend", + doc={"a": 1, "b": FLOAT_NEGATIVE_INFINITY}, + expression={"$subtract": ["$a", "$b"]}, + expected=FLOAT_INFINITY, + msg="$subtract of a number minus -Infinity should return Infinity", + ), + ExpressionTestCase( + "inf_minus_zero", + doc={"a": FLOAT_INFINITY, "b": INT32_ZERO}, + expression={"$subtract": ["$a", "$b"]}, + expected=FLOAT_INFINITY, + msg="$subtract of Infinity minus zero should return Infinity", + ), + ExpressionTestCase( + "neg_inf_minus_zero", + doc={"a": FLOAT_NEGATIVE_INFINITY, "b": INT32_ZERO}, + expression={"$subtract": ["$a", "$b"]}, + expected=FLOAT_NEGATIVE_INFINITY, + msg="$subtract of -Infinity minus zero should return -Infinity", + ), + ExpressionTestCase( + "inf_minus_inf", + doc={"a": FLOAT_INFINITY, "b": FLOAT_INFINITY}, + expression={"$subtract": ["$a", "$b"]}, + expected=pytest.approx(math.nan, nan_ok=True), + msg="$subtract of Infinity minus Infinity should return NaN", + ), + ExpressionTestCase( + "neg_inf_minus_neg_inf", + doc={"a": FLOAT_NEGATIVE_INFINITY, "b": FLOAT_NEGATIVE_INFINITY}, + expression={"$subtract": ["$a", "$b"]}, + expected=pytest.approx(math.nan, nan_ok=True), + msg="$subtract of -Infinity minus -Infinity should return NaN", + ), + # Decimal128 Infinity + ExpressionTestCase( + "decimal_infinity_minuend", + doc={"a": DECIMAL128_INFINITY, "b": 1}, + expression={"$subtract": ["$a", "$b"]}, + expected=DECIMAL128_INFINITY, + msg="$subtract of Decimal128 Infinity minus a number should return Decimal128 Infinity", + ), + ExpressionTestCase( + "decimal_negative_infinity_minuend", + doc={"a": DECIMAL128_NEGATIVE_INFINITY, "b": 1}, + expression={"$subtract": ["$a", "$b"]}, + expected=DECIMAL128_NEGATIVE_INFINITY, + msg="$subtract of Decimal128 -Infinity minus a number should return Decimal128 -Infinity", + ), + ExpressionTestCase( + "decimal_inf_minus_inf", + doc={"a": DECIMAL128_INFINITY, "b": DECIMAL128_INFINITY}, + expression={"$subtract": ["$a", "$b"]}, + expected=DECIMAL128_NAN, + msg="$subtract of Decimal128 Infinity minus Decimal128 Infinity should return Decimal128 NaN", # noqa: E501 + ), +] + +# Property [NaN propagation]: $subtract propagates NaN when either operand is NaN. +NAN_PROPAGATION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nan_minuend", + doc={"a": FLOAT_NAN, "b": 1}, + expression={"$subtract": ["$a", "$b"]}, + expected=pytest.approx(math.nan, nan_ok=True), + msg="$subtract with a NaN minuend should return NaN", + ), + ExpressionTestCase( + "nan_subtrahend", + doc={"a": 10, "b": FLOAT_NAN}, + expression={"$subtract": ["$a", "$b"]}, + expected=pytest.approx(math.nan, nan_ok=True), + msg="$subtract with a NaN subtrahend should return NaN", + ), + ExpressionTestCase( + "both_nan", + doc={"a": FLOAT_NAN, "b": FLOAT_NAN}, + expression={"$subtract": ["$a", "$b"]}, + expected=pytest.approx(math.nan, nan_ok=True), + msg="$subtract with both operands as NaN should return NaN", + ), + # Decimal128 NaN + ExpressionTestCase( + "decimal_nan_minuend", + doc={"a": DECIMAL128_NAN, "b": 1}, + expression={"$subtract": ["$a", "$b"]}, + expected=DECIMAL128_NAN, + msg="$subtract with a Decimal128 NaN minuend should return Decimal128 NaN", + ), + ExpressionTestCase( + "decimal_nan_subtrahend", + doc={"a": 10, "b": DECIMAL128_NAN}, + expression={"$subtract": ["$a", "$b"]}, + expected=DECIMAL128_NAN, + msg="$subtract with a Decimal128 NaN subtrahend should return Decimal128 NaN", + ), +] + +# Property [Inf - Inf = NaN]: subtracting equal signed infinities produces NaN. +INF_MINUS_INF_NAN_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "decimal_nan_minus_double", + doc={"a": DECIMAL128_NAN, "b": DOUBLE_ONE_AND_HALF}, + expression={"$subtract": ["$a", "$b"]}, + expected=DECIMAL128_NAN, + msg="$subtract of Decimal128 NaN minus a double should return Decimal128 NaN", + ), + ExpressionTestCase( + "double_minus_decimal_nan", + doc={"a": DOUBLE_ONE_AND_HALF, "b": DECIMAL128_NAN}, + expression={"$subtract": ["$a", "$b"]}, + expected=DECIMAL128_NAN, + msg="$subtract of a double minus Decimal128 NaN should return Decimal128 NaN", + ), + ExpressionTestCase( + "int_minus_decimal_inf", + doc={"a": 1, "b": DECIMAL128_INFINITY}, + expression={"$subtract": ["$a", "$b"]}, + expected=DECIMAL128_NEGATIVE_INFINITY, + msg="$subtract of an int minus Decimal128 Infinity should return Decimal128 -Infinity", + ), +] + +SUBTRACT_NON_FINITE_TESTS: list[ExpressionTestCase] = ( + INFINITY_PROPAGATION_TESTS + NAN_PROPAGATION_TESTS + INF_MINUS_INF_NAN_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(SUBTRACT_NON_FINITE_TESTS)) +def test_subtract_non_finite(collection, test_case: ExpressionTestCase): + """Test $subtract behavior with non-finite values (NaN and Infinity).""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/subtract/test_subtract_null.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/subtract/test_subtract_null.py new file mode 100644 index 000000000..3b31b3bed --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/subtract/test_subtract_null.py @@ -0,0 +1,83 @@ +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import MISSING + +# Property [Null propagation]: $subtract returns null when either operand is null or missing. +NULL_PROPAGATION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_subtrahend", + doc={"a": 10, "b": None}, + expression={"$subtract": ["$a", "$b"]}, + expected=None, + msg="$subtract should return null when the subtrahend is null", + ), + ExpressionTestCase( + "null_minuend", + doc={"a": None, "b": 5}, + expression={"$subtract": ["$a", "$b"]}, + expected=None, + msg="$subtract should return null when the minuend is null", + ), + ExpressionTestCase( + "both_null", + doc={"a": None, "b": None}, + expression={"$subtract": ["$a", "$b"]}, + expected=None, + msg="$subtract should return null when both operands are null", + ), + ExpressionTestCase( + "missing_subtrahend", + doc={"a": 10}, + expression={"$subtract": ["$a", MISSING]}, + expected=None, + msg="$subtract should return null when the subtrahend field is missing", + ), + ExpressionTestCase( + "missing_minuend", + doc={"b": 5}, + expression={"$subtract": [MISSING, "$b"]}, + expected=None, + msg="$subtract should return null when the minuend field is missing", + ), +] + +# Property [Null short-circuit]: a null/missing operand short-circuits evaluation before type +# checking. +NULL_SHORT_CIRCUIT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_with_non_numeric", + doc={"a": None}, + expression={"$subtract": ["$a", "string"]}, + expected=None, + msg="$subtract should return null when a null minuend short-circuits type checking", + ), + ExpressionTestCase( + "missing_with_non_numeric", + doc={}, + expression={"$subtract": [MISSING, True]}, + expected=None, + msg="$subtract should return null when a missing minuend short-circuits type checking", + ), +] + +SUBTRACT_NULL_TESTS: list[ExpressionTestCase] = NULL_PROPAGATION_TESTS + NULL_SHORT_CIRCUIT_TESTS + + +@pytest.mark.parametrize("test_case", pytest_params(SUBTRACT_NULL_TESTS)) +def test_subtract_null(collection, test_case: ExpressionTestCase): + """Test $subtract null and missing field propagation.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) From 4fba78595472d806791d4a30f6e74a3caf269c55 Mon Sep 17 00:00:00 2001 From: Daniel Frankcom Date: Mon, 13 Jul 2026 16:44:37 -0700 Subject: [PATCH 27/35] Add fsync command tests (#657) Signed-off-by: Daniel Frankcom Signed-off-by: RyanGarfinkel <113050972+RyanGarfinkel@users.noreply.github.com> --- docs/testing/FOLDER_STRUCTURE.md | 2 + .../administration/commands/fsync/conftest.py | 11 + .../fsync/test_fsync_command_envelope.py | 164 ++++++++++ .../fsync/test_fsync_execution_context.py | 67 +++++ .../commands/fsync/test_fsync_lock.py | 227 ++++++++++++++ .../test_fsync_lock_timeout_type_errors.py | 113 +++++++ .../fsync/test_fsync_no_lock_flush.py | 158 ++++++++++ .../commands/fsync/test_fsync_read_concern.py | 281 ++++++++++++++++++ .../fsync/test_fsync_write_concern.py | 116 ++++++++ 9 files changed, 1139 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/fsync/conftest.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/fsync/test_fsync_command_envelope.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/fsync/test_fsync_execution_context.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/fsync/test_fsync_lock.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/fsync/test_fsync_lock_timeout_type_errors.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/fsync/test_fsync_no_lock_flush.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/fsync/test_fsync_read_concern.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/fsync/test_fsync_write_concern.py diff --git a/docs/testing/FOLDER_STRUCTURE.md b/docs/testing/FOLDER_STRUCTURE.md index 63e85d3cd..9e9bb1c34 100644 --- a/docs/testing/FOLDER_STRUCTURE.md +++ b/docs/testing/FOLDER_STRUCTURE.md @@ -17,6 +17,8 @@ └── test_unwind_combined_options.py # multiple options together ``` +8. **Non-test files in a test folder** — a test folder may contain a few non-test Python files, which the structure validator exempts from the `test_`-prefix and parent-name naming rules: `__init__.py`, `conftest.py`, and any module under a `utils/` or `fixtures/` subfolder. Shared *logic and test data* live in `utils/`; `conftest.py` holds shared pytest *fixtures* scoped to that directory (e.g. an autouse baseline, or package-scoped setup). + ## Decision Tree **Step 1: Cross-cutting feature?** (rbac, transactions, collation, geospatial, text_search, validation, ttl) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/fsync/conftest.py b/documentdb_tests/compatibility/tests/system/administration/commands/fsync/conftest.py new file mode 100644 index 000000000..370722fbf --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/fsync/conftest.py @@ -0,0 +1,11 @@ +"""Shared fixtures for fsync tests. + +Re-exports the autouse fsync-lock baseline fixture so it applies to every test +in this directory without each test file importing it. +""" + +from documentdb_tests.compatibility.tests.system.administration.utils.fsync_lock import ( + unlocked_baseline, +) + +__all__ = ["unlocked_baseline"] diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/fsync/test_fsync_command_envelope.py b/documentdb_tests/compatibility/tests/system/administration/commands/fsync/test_fsync_command_envelope.py new file mode 100644 index 000000000..b68f59349 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/fsync/test_fsync_command_envelope.py @@ -0,0 +1,164 @@ +"""Tests for fsync command-envelope field handling. + +Covers comment acceptance, generic envelope options, unknown-field rejection, +and apiStrict rejection. +""" + +from __future__ import annotations + +from datetime import ( + datetime, + timezone, +) + +import pytest +from bson import ( + Binary, + Code, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import CommandTestCase +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + API_STRICT_ERROR, + UNRECOGNIZED_COMMAND_FIELD_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import ( + Eq, + NotExists, +) +from documentdb_tests.framework.test_constants import DECIMAL128_ONE_AND_HALF + +pytestmark = pytest.mark.no_parallel + + +# Property [Comment Acceptance]: comment accepts any BSON value and is not echoed +# in the response. +FSYNC_COMMENT_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"comment_{tid}", + command={"fsync": 1, "comment": val}, + expected={ + "ok": Eq(1.0), + "numFiles": Eq(1), + "lockCount": NotExists(), + "comment": NotExists(), + }, + msg=f"fsync should accept a {tid} comment value and not echo it in the response", + ) + for tid, val in [ + ("int32", 42), + ("int64", Int64(7)), + ("double", 3.14), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool", True), + ("string", "audit note"), + ("object", {"a": 1}), + ("array", [1, 2, 3]), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Generic Command Options Accepted]: generic command-envelope options +# ($readPreference, maxTimeMS, apiVersion) are accepted and ignored, leaving the +# no-lock flush response unchanged. Their semantics are owned elsewhere; this +# only confirms fsync accepts the syntax. +FSYNC_GENERIC_OPTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "generic_read_preference", + command={"fsync": 1, "$readPreference": {"mode": "secondary"}}, + expected={"ok": Eq(1.0), "numFiles": Eq(1), "lockCount": NotExists()}, + msg="fsync should accept a $readPreference option and perform a no-lock flush", + ), + CommandTestCase( + "generic_max_time_ms", + command={"fsync": 1, "maxTimeMS": 5_000}, + expected={"ok": Eq(1.0), "numFiles": Eq(1), "lockCount": NotExists()}, + msg="fsync should accept an int maxTimeMS option and perform a no-lock flush", + ), + CommandTestCase( + "generic_max_time_ms_float", + command={"fsync": 1, "maxTimeMS": 5_000.0}, + expected={"ok": Eq(1.0), "numFiles": Eq(1), "lockCount": NotExists()}, + msg="fsync should accept a float maxTimeMS option and perform a no-lock flush", + ), + CommandTestCase( + "generic_api_version", + command={"fsync": 1, "apiVersion": "1"}, + expected={"ok": Eq(1.0), "numFiles": Eq(1), "lockCount": NotExists()}, + msg="fsync should accept apiVersion 1 alone and perform a no-lock flush", + ), +] + +# Property [Unknown Field and Field-Name Case Sensitivity]: an unrecognized +# top-level field, including case variants of known field names, produces an +# unrecognized-field error. +FSYNC_UNKNOWN_FIELD_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "unknown_field", + command={"fsync": 1, "bogus": 1}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="fsync should reject an unknown top-level command field with an " + "unrecognized-field error", + ), + CommandTestCase( + "case_variant_lock", + command={"fsync": 1, "Lock": True}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="fsync should reject a case variant of a known command field name " + "with an unrecognized-field error", + ), + CommandTestCase( + "timeout_missing_millis_suffix", + command={"fsync": 1, "fsyncLockAcquisitionTimeout": 90_000}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="fsync should reject fsyncLockAcquisitionTimeout, which is missing the " + "Millis suffix, as an unknown field", + ), +] + +# Property [Unsupported Stable API]: apiStrict under API Version 1 is rejected +# rather than silently ignored. +FSYNC_API_STRICT_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "envelope_api_strict", + command={"fsync": 1, "apiVersion": "1", "apiStrict": True}, + error_code=API_STRICT_ERROR, + msg="fsync should reject apiStrict under API Version 1 with an APIStrictError", + ), +] + +FSYNC_COMMAND_ENVELOPE_TESTS: list[CommandTestCase] = ( + FSYNC_COMMENT_TESTS + + FSYNC_GENERIC_OPTION_TESTS + + FSYNC_UNKNOWN_FIELD_ERROR_TESTS + + FSYNC_API_STRICT_ERROR_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(FSYNC_COMMAND_ENVELOPE_TESTS)) +def test_fsync_command_envelope_cases(collection, test): + """Test fsync command-envelope option acceptance and rejection cases.""" + result = execute_admin_command(collection, test.command) + assertResult( + result, + expected=test.expected, + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/fsync/test_fsync_execution_context.py b/documentdb_tests/compatibility/tests/system/administration/commands/fsync/test_fsync_execution_context.py new file mode 100644 index 000000000..887a08278 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/fsync/test_fsync_execution_context.py @@ -0,0 +1,67 @@ +"""Tests for fsync invocation context: explicit session, authorization scope, and transactions.""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.framework.assertions import ( + assertFailureCode, + assertSuccessPartial, +) +from documentdb_tests.framework.error_codes import ( + OPERATION_NOT_SUPPORTED_IN_TRANSACTION_ERROR, + UNAUTHORIZED_ERROR, +) +from documentdb_tests.framework.executor import ( + execute_admin_command, + execute_command, +) + +pytestmark = pytest.mark.no_parallel + + +# Property [Explicit Session Accepted]: a no-lock flush issued under an explicit +# client session behaves identically to a sessionless invocation. +def test_fsync_accepts_explicit_session(collection): + """Test fsync runs a no-lock flush under an explicit client session.""" + session = collection.database.client.start_session() + try: + result = execute_admin_command(collection, {"fsync": 1}, session=session) + assertSuccessPartial( + result, + {"ok": 1.0, "numFiles": 1}, + msg="fsync should run a no-lock flush under an explicit session", + ) + finally: + session.end_session() + + +# Property [Authorization Scope]: fsync is admin-only, so running it against a +# non-admin database produces an Unauthorized error. +def test_fsync_rejects_non_admin_database(collection): + """Test fsync rejects execution against a non-admin database.""" + result = execute_command(collection, {"fsync": 1}) + assertFailureCode( + result, + UNAUTHORIZED_ERROR, + msg="fsync should reject a flush against a non-admin database", + ) + + +# Property [Multi-Document Transaction]: fsync is not supported inside a +# multi-document transaction and errors with OperationNotSupportedInTransaction. +@pytest.mark.requires(transactions=True) +def test_fsync_rejects_multi_document_transaction(collection): + """Test fsync errors when issued inside a multi-document transaction.""" + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + try: + result = execute_admin_command(collection, {"fsync": 1}, session=session) + finally: + session.abort_transaction() + assertFailureCode( + result, + OPERATION_NOT_SUPPORTED_IN_TRANSACTION_ERROR, + msg="fsync should error when issued inside a multi-document transaction", + ) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/fsync/test_fsync_lock.py b/documentdb_tests/compatibility/tests/system/administration/commands/fsync/test_fsync_lock.py new file mode 100644 index 000000000..5aa114828 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/fsync/test_fsync_lock.py @@ -0,0 +1,227 @@ +"""Tests for fsync lock acquisition, coercion, nesting, and persistence.""" + +from __future__ import annotations + +import pytest +from bson import Int64 + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import CommandTestCase +from documentdb_tests.framework import fixtures +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import ( + Eq, + Exists, + IsType, + NotExists, +) +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_ONE_AND_HALF, + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ONE_AND_HALF, + DECIMAL128_ZERO, + DOUBLE_HALF, + DOUBLE_NEGATIVE_ONE_AND_HALF, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + INT32_MAX, + INT32_MIN, + INT32_ZERO, + INT64_ZERO, +) + +pytestmark = pytest.mark.no_parallel + + +# Property [Lock Coercion - Falsy]: boolean false and any zero-magnitude numeric +# (including negative zero) coerce to false, acquiring no lock. +FSYNC_LOCK_FALSY_TESTS: list[CommandTestCase] = [ + *[ + CommandTestCase( + f"lock_falsy_{tid}", + command={"fsync": 1, "lock": val}, + expected={"ok": Eq(1.0), "numFiles": Eq(1), "lockCount": NotExists()}, + msg=f"fsync should treat a {tid} lock value as falsy and acquire no lock", + ) + for tid, val in [ + ("int_zero", INT32_ZERO), + ("int64_zero", INT64_ZERO), + ("double_zero", DOUBLE_ZERO), + ("double_negative_zero", DOUBLE_NEGATIVE_ZERO), + ("decimal_zero", DECIMAL128_ZERO), + ("decimal_negative_zero", DECIMAL128_NEGATIVE_ZERO), + ] + ], + CommandTestCase( + "lock_falsy_bool_false", + command={"fsync": 1, "lock": False}, + expected={"ok": Eq(1.0), "numFiles": Eq(1), "lockCount": NotExists()}, + msg="fsync should treat a boolean false lock value as falsy and acquire no lock", + ), + CommandTestCase( + "lock_falsy_with_timeout", + command={"fsync": 1, "lock": False, "fsyncLockAcquisitionTimeoutMillis": 5_000}, + expected={"ok": Eq(1.0), "numFiles": Eq(1), "lockCount": NotExists()}, + msg="fsync should perform a no-lock flush when lock is false even with a " + "timeout present", + ), +] + +# Property [Lock Acquisition and Response Shape]: a truthy lock acquires an +# fsync lock and returns info, an Int64 lockCount, and seeAlso with no numFiles; +# options supplied alongside lock do not alter the response. +FSYNC_LOCK_ACQUISITION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "lock_true", + command={"fsync": 1, "lock": True}, + expected={ + "info": Eq("now locked against writes, use db.fsyncUnlock() to unlock"), + "lockCount": [IsType("long"), Eq(Int64(1))], + "seeAlso": [Exists(), IsType("string")], + "ok": Eq(1.0), + "numFiles": NotExists(), + }, + msg="fsync should acquire a lock and return the lock response shape", + ), + CommandTestCase( + "lock_true_with_options", + command={ + "fsync": 1, + "lock": True, + "comment": "lock with options", + "fsyncLockAcquisitionTimeoutMillis": 90_000, + }, + expected={ + "info": [Exists(), IsType("string")], + "lockCount": [IsType("long"), Eq(Int64(1))], + "seeAlso": [Exists(), IsType("string")], + "ok": Eq(1.0), + "numFiles": NotExists(), + "comment": NotExists(), + }, + msg="fsync should acquire a lock when comment and timeout are also present", + ), +] + +# Property [Lock Coercion - Truthy]: any non-zero-magnitude numeric (including +# NaN and signed infinity) coerces to true and acquires a lock. +FSYNC_LOCK_TRUTHY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"lock_truthy_{tid}", + command={"fsync": 1, "lock": val}, + expected={ + "ok": Eq(1.0), + "lockCount": [IsType("long"), Eq(Int64(1))], + "numFiles": NotExists(), + }, + msg=f"fsync should treat a {tid} lock value as truthy and acquire a lock", + ) + for tid, val in [ + ("int_positive", 1), + ("int64_positive", Int64(2)), + ("double_positive", DOUBLE_HALF), + ("double_negative", DOUBLE_NEGATIVE_ONE_AND_HALF), + ("decimal_positive", DECIMAL128_ONE_AND_HALF), + ("decimal_negative", DECIMAL128_NEGATIVE_ONE_AND_HALF), + ("double_nan", FLOAT_NAN), + ("decimal_nan", DECIMAL128_NAN), + ("double_infinity", FLOAT_INFINITY), + ("double_negative_infinity", FLOAT_NEGATIVE_INFINITY), + ("decimal_infinity", DECIMAL128_INFINITY), + ("decimal_negative_infinity", DECIMAL128_NEGATIVE_INFINITY), + ] +] + +# Property [Timeout Value Accepted With Lock]: any int32 +# fsyncLockAcquisitionTimeoutMillis, including zero, negatives, and the int32 +# extremes, is accepted with no value-range validation. +FSYNC_TIMEOUT_WITH_LOCK_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"timeout_with_lock_{tid}", + command={ + "fsync": 1, + "lock": True, + "fsyncLockAcquisitionTimeoutMillis": val, + }, + expected={ + "ok": Eq(1.0), + "lockCount": [IsType("long"), Eq(Int64(1))], + "numFiles": NotExists(), + }, + msg=f"fsync should accept a {tid} timeout value and still acquire the lock", + ) + for tid, val in [ + ("zero", INT32_ZERO), + ("one", 1), + ("negative_one", -1), + ("int32_max", INT32_MAX), + ("int32_min", INT32_MIN), + ] +] + +FSYNC_LOCK_TESTS: list[CommandTestCase] = ( + FSYNC_LOCK_FALSY_TESTS + + FSYNC_LOCK_ACQUISITION_TESTS + + FSYNC_LOCK_TRUTHY_TESTS + + FSYNC_TIMEOUT_WITH_LOCK_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(FSYNC_LOCK_TESTS)) +def test_fsync_lock_cases(collection, test): + """Test fsync lock coercion, acquisition, and response-shape cases.""" + result = execute_admin_command(collection, test.command) + assertResult( + result, + expected=test.expected, + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + + +# Property [Lock Counting and Nesting]: a lock:true issued while the lock is +# already held nests rather than conflicting, returning a lockCount one higher +# than the existing depth. +def test_fsync_lock_nesting_increments_count(collection): + """Test fsync lock:true returns an incremented lockCount when already locked.""" + # Precondition: hold the lock twice so the command under test is the third. + execute_admin_command(collection, {"fsync": 1, "lock": True}) + execute_admin_command(collection, {"fsync": 1, "lock": True}) + result = execute_admin_command(collection, {"fsync": 1, "lock": True}) + assertResult( + result, + expected={"ok": Eq(1.0), "lockCount": [IsType("long"), Eq(Int64(3))]}, + msg="fsync should nest a lock while already held, returning lockCount 3", + raw_res=True, + ) + + +# Property [Lock Persistence Across Connection Close]: the server-global lock +# persists when the acquiring connection closes without unlocking, so a fresh +# lock:true returns lockCount 2 rather than 1. +def test_fsync_lock_persists_after_connection_close(connection_string, collection): + """Test fsync lock persists after the acquiring connection closes.""" + # Precondition: acquire the lock on a separate connection, then close that + # connection without unlocking. + second_client = fixtures.create_engine_client(connection_string, "second") + second_coll = second_client[collection.database.name][collection.name] + execute_admin_command(second_coll, {"fsync": 1, "lock": True}) + second_client.close() + # If the lock had been released on disconnect this would return lockCount 1; + # lockCount 2 proves the lock survived the close. + result = execute_admin_command(collection, {"fsync": 1, "lock": True}) + assertResult( + result, + expected={"ok": Eq(1.0), "lockCount": [IsType("long"), Eq(Int64(2))]}, + msg="fsync should still see the lock held after the acquiring connection " + "closes, returning lockCount 2", + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/fsync/test_fsync_lock_timeout_type_errors.py b/documentdb_tests/compatibility/tests/system/administration/commands/fsync/test_fsync_lock_timeout_type_errors.py new file mode 100644 index 000000000..9dbb63da2 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/fsync/test_fsync_lock_timeout_type_errors.py @@ -0,0 +1,113 @@ +"""Tests for fsync lock and timeout field type strictness.""" + +from __future__ import annotations + +from datetime import ( + datetime, + timezone, +) + +import pytest +from bson import ( + Binary, + Code, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import CommandTestCase +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import TYPE_MISMATCH_ERROR +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import DECIMAL128_ONE_AND_HALF + +pytestmark = pytest.mark.no_parallel + + +# Property [lock Type Strictness]: a non-numeric lock value produces a +# TypeMismatch error, and a literal array is not unwrapped. +FSYNC_LOCK_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + *[ + CommandTestCase( + f"lock_type_{tid}", + command={"fsync": 1, "lock": val}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"fsync should reject a {tid} lock value with a TypeMismatch error", + ) + for tid, val in [ + ("string", "x"), + ("object", {"a": 1}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] + ], + CommandTestCase( + "lock_array_true", + command={"fsync": 1, "lock": [True]}, + error_code=TYPE_MISMATCH_ERROR, + msg="fsync should reject a single-element truthy array lock value without unwrapping it", + ), + CommandTestCase( + "lock_array_false", + command={"fsync": 1, "lock": [False]}, + error_code=TYPE_MISMATCH_ERROR, + msg="fsync should reject a single-element falsy array lock value without unwrapping it", + ), +] + +# Property [fsyncLockAcquisitionTimeoutMillis Type Strictness]: a non-int32 +# timeout produces a TypeMismatch error with no coercion, and a literal array is +# not unwrapped. +FSYNC_TIMEOUT_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"timeout_type_{tid}", + command={"fsync": 1, "fsyncLockAcquisitionTimeoutMillis": val}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"fsync should reject a {tid} timeout value with a TypeMismatch error", + ) + for tid, val in [ + ("int64", Int64(100)), + ("double_whole", 90_000.0), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool", True), + ("string", "90000"), + ("object", {"a": 1}), + ("array", [42]), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +FSYNC_LOCK_TIMEOUT_TYPE_ERROR_TESTS: list[CommandTestCase] = ( + FSYNC_LOCK_TYPE_ERROR_TESTS + FSYNC_TIMEOUT_TYPE_ERROR_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(FSYNC_LOCK_TIMEOUT_TYPE_ERROR_TESTS)) +def test_fsync_lock_timeout_type_error_cases(collection, test): + """Test fsync lock and timeout type-strictness error cases.""" + result = execute_admin_command(collection, test.command) + assertResult( + result, + expected=test.expected, + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/fsync/test_fsync_no_lock_flush.py b/documentdb_tests/compatibility/tests/system/administration/commands/fsync/test_fsync_no_lock_flush.py new file mode 100644 index 000000000..0b8d98e61 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/fsync/test_fsync_no_lock_flush.py @@ -0,0 +1,158 @@ +"""Tests for fsync no-lock flush behavior and response shape.""" + +from __future__ import annotations + +from datetime import ( + datetime, + timezone, +) + +import pytest +from bson import ( + Binary, + Code, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import CommandTestCase +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import ( + Eq, + IsType, + NotExists, +) +from documentdb_tests.framework.test_constants import DECIMAL128_ONE_AND_HALF + +pytestmark = pytest.mark.no_parallel + + +# Property [Null and Missing Behavior]: a null or missing value in any field is +# treated as absent, performing a no-lock flush. +FSYNC_NULL_MISSING_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "fsync_key_null", + command={"fsync": None}, + expected={"ok": Eq(1.0), "numFiles": Eq(1), "lockCount": NotExists()}, + msg="fsync should perform a no-lock flush when the command-key value is null", + ), + CommandTestCase( + "lock_null", + command={"fsync": 1, "lock": None}, + expected={"ok": Eq(1.0), "numFiles": Eq(1), "lockCount": NotExists()}, + msg="fsync should perform a no-lock flush when lock is null", + ), + CommandTestCase( + "timeout_null", + command={"fsync": 1, "fsyncLockAcquisitionTimeoutMillis": None}, + expected={"ok": Eq(1.0), "numFiles": Eq(1), "lockCount": NotExists()}, + msg="fsync should perform a no-lock flush when " + "fsyncLockAcquisitionTimeoutMillis is null", + ), + CommandTestCase( + "comment_null", + command={"fsync": 1, "comment": None}, + expected={"ok": Eq(1.0), "numFiles": Eq(1), "lockCount": NotExists()}, + msg="fsync should perform a no-lock flush when comment is null", + ), + CommandTestCase( + "all_fields_null", + command={ + "fsync": None, + "lock": None, + "fsyncLockAcquisitionTimeoutMillis": None, + "comment": None, + }, + expected={"ok": Eq(1.0), "numFiles": Eq(1), "lockCount": NotExists()}, + msg="fsync should perform a no-lock flush when every field is null", + ), + CommandTestCase( + "optional_fields_missing", + command={"fsync": 1}, + expected={"ok": Eq(1.0), "numFiles": Eq(1), "lockCount": NotExists()}, + msg="fsync should perform a no-lock flush when all optional fields are omitted", + ), +] + +# Property [Flush Response Shape]: a no-lock flush returns an integer numFiles +# and none of the lock-only fields. +FSYNC_FLUSH_SHAPE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "flush_shape", + command={"fsync": 1}, + expected={ + "ok": Eq(1.0), + "numFiles": IsType("int"), + "lockCount": NotExists(), + "info": NotExists(), + "seeAlso": NotExists(), + }, + msg="fsync should return an integer numFiles and no lock-only fields on a no-lock flush", + ), +] + +# Property [Command-Key Value Ignored]: the fsync command-key value is never +# type-validated. +FSYNC_KEY_IGNORED_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"key_{tid}", + command={"fsync": val}, + expected={"ok": Eq(1.0), "numFiles": Eq(1), "lockCount": NotExists()}, + msg=f"fsync should ignore a {tid} command-key value and perform a no-lock flush", + ) + for tid, val in [ + ("int32", 42), + ("int64", Int64(7)), + ("double", 3.14), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool", True), + ("string", "flush"), + ("object", {"a": 1}), + ("array", [1, 2, 3]), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Timeout Value Without Lock]: fsyncLockAcquisitionTimeoutMillis is +# accepted and has no effect when no lock is requested. +FSYNC_TIMEOUT_NO_LOCK_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "timeout_no_lock", + command={"fsync": 1, "fsyncLockAcquisitionTimeoutMillis": 5_000}, + expected={"ok": Eq(1.0), "numFiles": Eq(1), "lockCount": NotExists()}, + msg="fsync should accept a timeout value without lock and perform a no-lock flush", + ), +] + +FSYNC_NO_LOCK_FLUSH_TESTS: list[CommandTestCase] = ( + FSYNC_NULL_MISSING_TESTS + + FSYNC_FLUSH_SHAPE_TESTS + + FSYNC_KEY_IGNORED_TESTS + + FSYNC_TIMEOUT_NO_LOCK_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(FSYNC_NO_LOCK_FLUSH_TESTS)) +def test_fsync_no_lock_flush_cases(collection, test): + """Test fsync no-lock flush success and response-shape cases.""" + result = execute_admin_command(collection, test.command) + assertResult( + result, + expected=test.expected, + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/fsync/test_fsync_read_concern.py b/documentdb_tests/compatibility/tests/system/administration/commands/fsync/test_fsync_read_concern.py new file mode 100644 index 000000000..e1c6ac1bc --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/fsync/test_fsync_read_concern.py @@ -0,0 +1,281 @@ +"""Tests for fsync readConcern acceptance and rejection behavior.""" + +from __future__ import annotations + +from datetime import ( + datetime, + timezone, +) + +import pytest +from bson import ( + Binary, + Code, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import CommandTestCase +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + ILLEGAL_OPERATION_ERROR, + INVALID_OPTIONS_ERROR, + TYPE_MISMATCH_ERROR, + UNRECOGNIZED_COMMAND_FIELD_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import ( + Eq, + NotExists, +) +from documentdb_tests.framework.test_constants import DECIMAL128_ONE_AND_HALF + +pytestmark = pytest.mark.no_parallel + + +# Property [readConcern Level Acceptance]: fsync supports the local read concern +# level; an empty document, a null readConcern, and a null level are all treated +# as absent. +FSYNC_READ_CONCERN_ACCEPTED_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "read_concern_level_local", + command={"fsync": 1, "readConcern": {"level": "local"}}, + expected={"ok": Eq(1.0), "numFiles": Eq(1), "lockCount": NotExists()}, + msg="fsync should accept readConcern level local and perform a no-lock flush", + ), + CommandTestCase( + "read_concern_empty", + command={"fsync": 1, "readConcern": {}}, + expected={"ok": Eq(1.0), "numFiles": Eq(1), "lockCount": NotExists()}, + msg="fsync should accept an empty readConcern document and perform a no-lock flush", + ), + CommandTestCase( + "read_concern_null", + command={"fsync": 1, "readConcern": None}, + expected={"ok": Eq(1.0), "numFiles": Eq(1), "lockCount": NotExists()}, + msg="fsync should treat a null readConcern as absent and perform a no-lock flush", + ), + CommandTestCase( + "read_concern_level_null", + command={"fsync": 1, "readConcern": {"level": None}}, + expected={"ok": Eq(1.0), "numFiles": Eq(1), "lockCount": NotExists()}, + msg="fsync should treat a null readConcern level as absent and perform a no-lock flush", + ), +] + +# Property [readConcern Level Not Supported]: every recognized read concern +# level other than local is rejected with an InvalidOptions error. +FSYNC_READ_CONCERN_LEVEL_REJECTED_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"read_concern_level_{level}", + command={"fsync": 1, "readConcern": {"level": level}}, + error_code=INVALID_OPTIONS_ERROR, + msg=f"fsync should reject readConcern level {level} with an InvalidOptions error", + ) + for level in ["majority", "available", "linearizable", "snapshot"] +] + +# Property [readConcern Invalid Level Value]: a readConcern level string that is +# not a recognized enum value is rejected with a BadValue error. +FSYNC_READ_CONCERN_INVALID_LEVEL_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "read_concern_level_unknown", + command={"fsync": 1, "readConcern": {"level": "bogus"}}, + error_code=BAD_VALUE_ERROR, + msg="fsync should reject an unrecognized readConcern level value with a BadValue error", + ), +] + +# Property [readConcern Level Type Strictness]: a readConcern level whose BSON +# type is not a string produces a TypeMismatch error. +FSYNC_READ_CONCERN_LEVEL_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"read_concern_level_type_{tid}", + command={"fsync": 1, "readConcern": {"level": val}}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"fsync should reject a {tid} readConcern level with a TypeMismatch error", + ) + for tid, val in [ + ("int32", 42), + ("int64", Int64(7)), + ("double", 3.14), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool", True), + ("object", {"a": 1}), + ("array", ["local"]), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [readConcern Type Strictness]: a non-document readConcern produces a +# TypeMismatch error (null is treated as absent, covered by the acceptance +# property). +FSYNC_READ_CONCERN_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"read_concern_type_{tid}", + command={"fsync": 1, "readConcern": val}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"fsync should reject a {tid} readConcern value as a non-document " + "with a TypeMismatch error", + ) + for tid, val in [ + ("int32", 42), + ("int64", Int64(7)), + ("double", 3.14), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool", True), + ("string", "local"), + ("array", [{"level": "local"}]), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [readConcern Unknown Sub-Field]: an unrecognized field inside the +# readConcern document produces an unrecognized-field error. +FSYNC_READ_CONCERN_UNKNOWN_SUBFIELD_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "read_concern_unknown_subfield", + command={"fsync": 1, "readConcern": {"level": "local", "bogus": 1}}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="fsync should reject an unknown sub-field inside readConcern with an " + "unrecognized-field error", + ), +] + +# Property [readConcern afterClusterTime Sub-field]: afterClusterTime must be a +# Timestamp and is honored only where replication-dependent read concern is +# available. +FSYNC_READ_CONCERN_AFTER_CLUSTER_TIME_TESTS: list[CommandTestCase] = [ + *[ + CommandTestCase( + f"read_concern_after_cluster_time_type_{tid}", + command={"fsync": 1, "readConcern": {"afterClusterTime": val}}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"fsync should reject a {tid} afterClusterTime value with a TypeMismatch error", + ) + for tid, val in [ + ("int32", 42), + ("int64", Int64(7)), + ("double", 3.14), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool", True), + ("string", "local"), + ("null", None), + ("object", {"a": 1}), + ("array", [Timestamp(1, 1)]), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] + ], + CommandTestCase( + "read_concern_after_cluster_time_null_timestamp", + command={"fsync": 1, "readConcern": {"afterClusterTime": Timestamp(0, 0)}}, + error_code=INVALID_OPTIONS_ERROR, + msg="fsync should reject a null timestamp (Timestamp(0, 0)) afterClusterTime " + "with an InvalidOptions error", + ), + CommandTestCase( + "read_concern_after_cluster_time_accepted", + command={"fsync": 1, "readConcern": {"afterClusterTime": Timestamp(1, 1)}}, + expected={"ok": Eq(1.0), "numFiles": Eq(1), "lockCount": NotExists()}, + msg="fsync should accept a non-zero afterClusterTime and perform a no-lock " + "flush where replication-dependent read concern is available", + marks=(pytest.mark.requires(cluster_read_concern=True),), + ), + CommandTestCase( + "read_concern_after_cluster_time_no_replication", + command={"fsync": 1, "readConcern": {"afterClusterTime": Timestamp(1, 1)}}, + error_code=ILLEGAL_OPERATION_ERROR, + msg="fsync should reject a non-zero afterClusterTime with an IllegalOperation " + "error where replication-dependent read concern is unavailable", + marks=(pytest.mark.requires(cluster_read_concern=False),), + ), +] + +# Property [readConcern atClusterTime Sub-field]: atClusterTime must be a +# Timestamp and is rejected because fsync does not support the snapshot level it +# requires. +FSYNC_READ_CONCERN_AT_CLUSTER_TIME_TESTS: list[CommandTestCase] = [ + *[ + CommandTestCase( + f"read_concern_at_cluster_time_type_{tid}", + command={"fsync": 1, "readConcern": {"atClusterTime": val}}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"fsync should reject a {tid} atClusterTime value with a TypeMismatch error", + ) + for tid, val in [ + ("int32", 42), + ("int64", Int64(7)), + ("double", 3.14), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool", True), + ("string", "local"), + ("null", None), + ("object", {"a": 1}), + ("array", [Timestamp(1, 1)]), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] + ], + CommandTestCase( + "read_concern_at_cluster_time_requires_snapshot", + command={"fsync": 1, "readConcern": {"atClusterTime": Timestamp(1, 1)}}, + error_code=INVALID_OPTIONS_ERROR, + msg="fsync should reject an atClusterTime timestamp with an InvalidOptions " + "error because it requires the unsupported snapshot level", + ), +] + +FSYNC_READ_CONCERN_TESTS: list[CommandTestCase] = ( + FSYNC_READ_CONCERN_ACCEPTED_TESTS + + FSYNC_READ_CONCERN_LEVEL_REJECTED_TESTS + + FSYNC_READ_CONCERN_INVALID_LEVEL_TESTS + + FSYNC_READ_CONCERN_LEVEL_TYPE_ERROR_TESTS + + FSYNC_READ_CONCERN_TYPE_ERROR_TESTS + + FSYNC_READ_CONCERN_UNKNOWN_SUBFIELD_TESTS + + FSYNC_READ_CONCERN_AFTER_CLUSTER_TIME_TESTS + + FSYNC_READ_CONCERN_AT_CLUSTER_TIME_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(FSYNC_READ_CONCERN_TESTS)) +def test_fsync_read_concern_cases(collection, test): + """Test fsync readConcern acceptance and rejection cases.""" + result = execute_admin_command(collection, test.command) + assertResult( + result, + expected=test.expected, + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/fsync/test_fsync_write_concern.py b/documentdb_tests/compatibility/tests/system/administration/commands/fsync/test_fsync_write_concern.py new file mode 100644 index 000000000..875fbb3ce --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/fsync/test_fsync_write_concern.py @@ -0,0 +1,116 @@ +"""Tests for fsync writeConcern rejection and type strictness.""" + +from __future__ import annotations + +from datetime import ( + datetime, + timezone, +) + +import pytest +from bson import ( + Binary, + Code, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import CommandTestCase +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + INVALID_OPTIONS_ERROR, + TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import ( + Eq, + NotExists, +) +from documentdb_tests.framework.test_constants import DECIMAL128_ONE_AND_HALF + +pytestmark = pytest.mark.no_parallel + + +# Property [writeConcern Not Supported]: fsync does not support writeConcern, so +# a writeConcern document is rejected with an InvalidOptions error regardless of +# its sub-fields (a null writeConcern is treated as absent, covered separately). +FSYNC_WRITE_CONCERN_REJECTED_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"write_concern_{tid}", + command={"fsync": 1, "writeConcern": val}, + error_code=INVALID_OPTIONS_ERROR, + msg=f"fsync should reject a writeConcern document ({tid}) with an InvalidOptions error", + ) + for tid, val in [ + ("empty", {}), + ("w_one", {"w": 1}), + ("w_zero", {"w": 0}), + ("w_majority", {"w": "majority"}), + ("j_true", {"j": True}), + ("wtimeout", {"w": 1, "wtimeout": 1_000}), + ] +] + +# Property [writeConcern Type Strictness]: a non-document writeConcern produces a +# TypeMismatch error. +FSYNC_WRITE_CONCERN_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"write_concern_type_{tid}", + command={"fsync": 1, "writeConcern": val}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"fsync should reject a {tid} writeConcern value as a non-document " + "with a TypeMismatch error", + ) + for tid, val in [ + ("int32", 42), + ("int64", Int64(7)), + ("double", 3.14), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool", True), + ("string", "majority"), + ("array", [{"w": 1}]), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [writeConcern Null Treated As Absent]: a null writeConcern is ignored +# rather than triggering the unsupported-option rejection. +FSYNC_WRITE_CONCERN_NULL_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "write_concern_null", + command={"fsync": 1, "writeConcern": None}, + expected={"ok": Eq(1.0), "numFiles": Eq(1), "lockCount": NotExists()}, + msg="fsync should treat a null writeConcern as absent and perform a no-lock flush", + ), +] + +FSYNC_WRITE_CONCERN_TESTS: list[CommandTestCase] = ( + FSYNC_WRITE_CONCERN_REJECTED_TESTS + + FSYNC_WRITE_CONCERN_TYPE_ERROR_TESTS + + FSYNC_WRITE_CONCERN_NULL_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(FSYNC_WRITE_CONCERN_TESTS)) +def test_fsync_write_concern_cases(collection, test): + """Test fsync writeConcern rejection, type-strictness, and null-as-absent cases.""" + result = execute_admin_command(collection, test.command) + assertResult( + result, + expected=test.expected, + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) From 497249425c3a5b532b0f0181c06aa25cdf65938a Mon Sep 17 00:00:00 2001 From: "Paterson.How" <71473631+PatersonProjects@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:00:57 -0700 Subject: [PATCH 28/35] Add $toInt, $toDouble, $toLong, $toDecimal tests (#684) Signed-off-by: PatersonProjects Co-authored-by: PatersonProjects Co-authored-by: Daniel Frankcom Signed-off-by: RyanGarfinkel <113050972+RyanGarfinkel@users.noreply.github.com> --- .../expressions/type/toDecimal/__init__.py | 0 .../type/toDecimal/test_toDecimal_arity.py | 80 +++ .../type/toDecimal/test_toDecimal_datetime.py | 169 +++++++ .../toDecimal/test_toDecimal_decimal128.py | 140 ++++++ .../type/toDecimal/test_toDecimal_double.py | 147 ++++++ .../toDecimal/test_toDecimal_field_ref.py | 148 ++++++ .../type/toDecimal/test_toDecimal_numeric.py | 182 +++++++ .../toDecimal/test_toDecimal_return_type.py | 131 +++++ .../type/toDecimal/test_toDecimal_string.py | 225 +++++++++ .../toDecimal/test_toDecimal_string_errors.py | 228 +++++++++ .../expressions/type/toDouble/__init__.py | 0 .../type/toDouble/test_toDouble_arity.py | 85 ++++ .../toDouble/test_toDouble_datetime_binary.py | 212 ++++++++ .../type/toDouble/test_toDouble_decimal128.py | 104 ++++ .../type/toDouble/test_toDouble_field_ref.py | 130 +++++ .../type/toDouble/test_toDouble_numeric.py | 248 +++++++++ .../toDouble/test_toDouble_return_type.py | 139 +++++ .../type/toDouble/test_toDouble_string.py | 304 +++++++++++ .../expressions/type/toInt/__init__.py | 0 .../type/toInt/test_toInt_arity.py | 83 +++ .../type/toInt/test_toInt_binary.py | 220 ++++++++ .../type/toInt/test_toInt_decimal128.py | 129 +++++ .../type/toInt/test_toInt_double.py | 147 ++++++ .../type/toInt/test_toInt_field_ref.py | 134 +++++ .../type/toInt/test_toInt_numeric.py | 151 ++++++ .../type/toInt/test_toInt_return_type.py | 126 +++++ .../type/toInt/test_toInt_string.py | 473 ++++++++++++++++++ .../expressions/type/toLong/__init__.py | 0 .../type/toLong/test_toLong_arity.py | 80 +++ .../toLong/test_toLong_datetime_binary.py | 237 +++++++++ .../type/toLong/test_toLong_decimal128.py | 174 +++++++ .../type/toLong/test_toLong_field_ref.py | 143 ++++++ .../type/toLong/test_toLong_numeric.py | 272 ++++++++++ .../type/toLong/test_toLong_return_type.py | 159 ++++++ .../type/toLong/test_toLong_string.py | 338 +++++++++++++ documentdb_tests/framework/test_constants.py | 1 + 36 files changed, 5539 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/test_toDecimal_arity.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/test_toDecimal_datetime.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/test_toDecimal_decimal128.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/test_toDecimal_double.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/test_toDecimal_field_ref.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/test_toDecimal_numeric.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/test_toDecimal_return_type.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/test_toDecimal_string.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/test_toDecimal_string_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/type/toDouble/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/type/toDouble/test_toDouble_arity.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/type/toDouble/test_toDouble_datetime_binary.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/type/toDouble/test_toDouble_decimal128.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/type/toDouble/test_toDouble_field_ref.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/type/toDouble/test_toDouble_numeric.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/type/toDouble/test_toDouble_return_type.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/type/toDouble/test_toDouble_string.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/type/toInt/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/type/toInt/test_toInt_arity.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/type/toInt/test_toInt_binary.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/type/toInt/test_toInt_decimal128.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/type/toInt/test_toInt_double.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/type/toInt/test_toInt_field_ref.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/type/toInt/test_toInt_numeric.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/type/toInt/test_toInt_return_type.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/type/toInt/test_toInt_string.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/type/toLong/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/type/toLong/test_toLong_arity.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/type/toLong/test_toLong_datetime_binary.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/type/toLong/test_toLong_decimal128.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/type/toLong/test_toLong_field_ref.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/type/toLong/test_toLong_numeric.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/type/toLong/test_toLong_return_type.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/type/toLong/test_toLong_string.py diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/test_toDecimal_arity.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/test_toDecimal_arity.py new file mode 100644 index 000000000..f48172eb1 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/test_toDecimal_arity.py @@ -0,0 +1,80 @@ +"""$toDecimal arity and field path syntax tests.""" + +import pytest +from bson import Decimal128 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, +) +from documentdb_tests.framework.error_codes import ( + FAILED_TO_PARSE_ERROR, + INVALID_DOLLAR_FIELD_PATH, + TO_TYPE_ARITY_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Arity]: $toDecimal unwraps a single-element literal array at parse time; +# empty and multi-element arrays are arity errors. +TODECIMAL_ARITY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "single_element", + msg="Single-element literal array argument is unwrapped to a scalar", + expression={"$toDecimal": [42]}, + expected=Decimal128("42"), + ), + ExpressionTestCase( + "single_null", + msg="Single-element literal array wrapping null unwraps and returns null", + expression={"$toDecimal": [None]}, + expected=None, + ), + ExpressionTestCase( + "empty_array", + msg="Empty literal array argument is an arity error", + expression={"$toDecimal": []}, + error_code=TO_TYPE_ARITY_ERROR, + ), + ExpressionTestCase( + "multi_element", + msg="Multi-element literal array argument is an arity error", + expression={"$toDecimal": [1, 2]}, + error_code=TO_TYPE_ARITY_ERROR, + ), + ExpressionTestCase( + "large_array", + msg="Large literal array argument is an arity error", + expression={"$toDecimal": list(range(100))}, + error_code=TO_TYPE_ARITY_ERROR, + ), +] + +# Property [Invalid Field Path]: $toDecimal rejects malformed field path syntax. +TODECIMAL_INVALID_FIELD_PATH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "bare_dollar", + msg="Bare '$' is an invalid field path", + expression={"$toDecimal": "$"}, + error_code=INVALID_DOLLAR_FIELD_PATH, + ), + ExpressionTestCase( + "double_dollar", + msg="'$$' is rejected as an empty variable name", + expression={"$toDecimal": "$$"}, + error_code=FAILED_TO_PARSE_ERROR, + ), +] + + +@pytest.mark.parametrize( + "test", pytest_params(TODECIMAL_ARITY_TESTS + TODECIMAL_INVALID_FIELD_PATH_TESTS) +) +def test_toDecimal_arity(collection, test: ExpressionTestCase): + """$toDecimal literal array arguments are unwrapped or rejected based on arity.""" + result = execute_expression(collection, test.expression) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/test_toDecimal_datetime.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/test_toDecimal_datetime.py new file mode 100644 index 000000000..b65c98f5e --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/test_toDecimal_datetime.py @@ -0,0 +1,169 @@ +"""$toDecimal datetime conversion tests and unsupported BSON type errors.""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, +) +from documentdb_tests.framework.error_codes import CONVERSION_FAILURE_ERROR +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DATE_BEFORE_EPOCH, + DATE_EPOCH, + DATE_YEAR_1, + DATE_YEAR_9999, + DECIMAL128_ZERO, +) + +# Property [Datetime]: $toDecimal converts datetime to milliseconds since Unix epoch as Decimal128. +TODECIMAL_DATETIME_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "datetime_epoch", + msg="Epoch datetime converts to Decimal128('0')", + expression={"$toDecimal": DATE_EPOCH}, + expected=DECIMAL128_ZERO, + ), + ExpressionTestCase( + "datetime_1ms_after_epoch", + msg="1 ms after epoch converts to Decimal128('1')", + expression={"$toDecimal": datetime(1970, 1, 1, 0, 0, 0, 1_000, tzinfo=timezone.utc)}, + expected=Decimal128("1"), + ), + ExpressionTestCase( + "datetime_one_day", + msg="One day after epoch converts to Decimal128('86400000')", + expression={"$toDecimal": datetime(1970, 1, 2, 0, 0, 0, tzinfo=timezone.utc)}, + expected=Decimal128("86400000"), + ), + ExpressionTestCase( + "datetime_before_epoch", + msg="1 ms before epoch converts to Decimal128('-1')", + expression={"$toDecimal": DATE_BEFORE_EPOCH}, + expected=Decimal128("-1"), + ), + ExpressionTestCase( + "datetime_pre_epoch", + msg="Pre-epoch date (1960-01-01) converts to negative ms value", + expression={"$toDecimal": datetime(1960, 1, 1, tzinfo=timezone.utc)}, + expected=Decimal128("-315619200000"), + ), + ExpressionTestCase( + "datetime_with_millis", + msg="Datetime with sub-second precision preserves milliseconds", + expression={"$toDecimal": datetime(2018, 3, 27, 5, 4, 47, 890_000, tzinfo=timezone.utc)}, + expected=Decimal128("1522127087890"), + ), + ExpressionTestCase( + "datetime_2024", + msg="A modern date converts to its ms-since-epoch value", + expression={"$toDecimal": datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc)}, + expected=Decimal128("1704067200000"), + ), + ExpressionTestCase( + "datetime_millisecond_precision", + msg="Datetime preserves millisecond precision", + expression={"$toDecimal": datetime(1970, 1, 1, 0, 0, 0, 500_000, tzinfo=timezone.utc)}, + expected=Decimal128("500"), + ), + ExpressionTestCase( + "datetime_far_past", + msg="Far-past datetime (year 1) converts to its ms-since-epoch value", + expression={"$toDecimal": DATE_YEAR_1}, + expected=Decimal128("-62135596800000"), + ), + ExpressionTestCase( + "datetime_far_future", + msg="Far-future datetime (year 9999) converts to its ms-since-epoch value", + expression={"$toDecimal": DATE_YEAR_9999}, + expected=Decimal128("253402300799999"), + ), +] + +# Property [Unsupported Types]: $toDecimal fails with a conversion error for BSON types it +# cannot convert (object, binary, ObjectId, regex, timestamp, code, MinKey, MaxKey, array). +TODECIMAL_UNSUPPORTED_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "type_object", + msg="Object BSON type is a conversion failure", + expression={"$toDecimal": {"$literal": {"a": 1}}}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "type_binary", + msg="Binary BSON type is a conversion failure", + expression={"$toDecimal": Binary(b"data")}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "type_binary_uuid", + msg="Binary UUID subtype 4 is a conversion failure", + expression={"$toDecimal": Binary(b"\x00" * 16, 4)}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "type_objectid", + msg="ObjectId BSON type is a conversion failure", + expression={"$toDecimal": ObjectId("507f1f77bcf86cd799439011")}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "type_regex", + msg="Regex BSON type is a conversion failure", + expression={"$toDecimal": Regex("abc")}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "type_timestamp", + msg="Timestamp BSON type is a conversion failure", + expression={"$toDecimal": Timestamp(1, 1)}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "type_code", + msg="Code BSON type is a conversion failure", + expression={"$toDecimal": Code("function() {}")}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "type_minkey", + msg="MinKey BSON type is a conversion failure", + expression={"$toDecimal": MinKey()}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "type_maxkey", + msg="MaxKey BSON type is a conversion failure", + expression={"$toDecimal": MaxKey()}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "type_array", + msg="Array value (from $literal) is a conversion failure", + expression={"$toDecimal": {"$literal": [1, 2]}}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "type_nested_array", + msg="Nested literal array after single-element unwrap is a conversion failure", + expression={"$toDecimal": [["hello"]]}, + error_code=CONVERSION_FAILURE_ERROR, + ), +] + +TODECIMAL_DATETIME_TESTS = TODECIMAL_DATETIME_TESTS + TODECIMAL_UNSUPPORTED_TYPE_TESTS + + +@pytest.mark.parametrize("test", pytest_params(TODECIMAL_DATETIME_TESTS)) +def test_toDecimal_datetime(collection, test: ExpressionTestCase): + """$toDecimal converts datetime to ms-since-epoch Decimal128; rejects unsupported types.""" + result = execute_expression(collection, test.expression) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/test_toDecimal_decimal128.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/test_toDecimal_decimal128.py new file mode 100644 index 000000000..3c9fbf975 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/test_toDecimal_decimal128.py @@ -0,0 +1,140 @@ +"""$toDecimal Decimal128 passthrough tests: identity, trailing zeros, exponent, and specials.""" + +import pytest +from bson import Decimal128 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_LARGE_EXPONENT, + DECIMAL128_MANY_TRAILING_ZEROS, + DECIMAL128_MAX, + DECIMAL128_MAX_COEFFICIENT, + DECIMAL128_MAX_NEGATIVE, + DECIMAL128_MIN, + DECIMAL128_MIN_POSITIVE, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_NAN, + DECIMAL128_NEGATIVE_ONE_AND_HALF, + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_TRAILING_ZERO, + DECIMAL128_ZERO, +) + +# Property [Decimal128 Passthrough]: $toDecimal is the identity function for Decimal128 inputs, +# preserving trailing zeros, exponent form, sign bits, and special values exactly. +TODECIMAL_DECIMAL128_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "dec128_zero", + msg="Decimal128 zero passes through unchanged", + expression={"$toDecimal": DECIMAL128_ZERO}, + expected=DECIMAL128_ZERO, + ), + ExpressionTestCase( + "dec128_negative_zero", + msg="Decimal128 negative zero passes through preserving sign", + expression={"$toDecimal": DECIMAL128_NEGATIVE_ZERO}, + expected=DECIMAL128_NEGATIVE_ZERO, + ), + ExpressionTestCase( + "dec128_one", + msg="Decimal128 1 passes through unchanged", + expression={"$toDecimal": Decimal128("1")}, + expected=Decimal128("1"), + ), + ExpressionTestCase( + "dec128_trailing_zero", + msg="Decimal128 trailing zero (1.0) passes through preserving the trailing zero", + expression={"$toDecimal": DECIMAL128_TRAILING_ZERO}, + expected=DECIMAL128_TRAILING_ZERO, + ), + ExpressionTestCase( + "dec128_many_trailing_zeros", + msg="Decimal128 with many trailing zeros passes through preserving all zeros", + expression={"$toDecimal": DECIMAL128_MANY_TRAILING_ZEROS}, + expected=DECIMAL128_MANY_TRAILING_ZEROS, + ), + ExpressionTestCase( + "dec128_exponent_form", + msg="Decimal128 in exponent form (1E+6144) passes through preserving exponent", + expression={"$toDecimal": DECIMAL128_LARGE_EXPONENT}, + expected=DECIMAL128_LARGE_EXPONENT, + ), + ExpressionTestCase( + "dec128_negative", + msg="Decimal128 negative value passes through unchanged", + expression={"$toDecimal": DECIMAL128_NEGATIVE_ONE_AND_HALF}, + expected=DECIMAL128_NEGATIVE_ONE_AND_HALF, + ), + ExpressionTestCase( + "dec128_max", + msg="Decimal128 max value passes through unchanged", + expression={"$toDecimal": DECIMAL128_MAX}, + expected=DECIMAL128_MAX, + ), + ExpressionTestCase( + "dec128_min", + msg="Decimal128 min value passes through unchanged", + expression={"$toDecimal": DECIMAL128_MIN}, + expected=DECIMAL128_MIN, + ), + ExpressionTestCase( + "dec128_min_positive", + msg="Decimal128 min positive value (1E-6176) passes through unchanged", + expression={"$toDecimal": DECIMAL128_MIN_POSITIVE}, + expected=DECIMAL128_MIN_POSITIVE, + ), + ExpressionTestCase( + "dec128_max_negative", + msg="Decimal128 max negative value (-1E-6176) passes through unchanged", + expression={"$toDecimal": DECIMAL128_MAX_NEGATIVE}, + expected=DECIMAL128_MAX_NEGATIVE, + ), + ExpressionTestCase( + "dec128_nan", + msg="Decimal128 NaN passes through unchanged", + expression={"$toDecimal": DECIMAL128_NAN}, + expected=DECIMAL128_NAN, + ), + ExpressionTestCase( + "dec128_negative_nan", + msg="Decimal128 -NaN passes through preserving sign bit", + expression={"$toDecimal": DECIMAL128_NEGATIVE_NAN}, + expected=DECIMAL128_NEGATIVE_NAN, + ), + ExpressionTestCase( + "dec128_infinity", + msg="Decimal128 Infinity passes through unchanged", + expression={"$toDecimal": DECIMAL128_INFINITY}, + expected=DECIMAL128_INFINITY, + ), + ExpressionTestCase( + "dec128_negative_infinity", + msg="Decimal128 -Infinity passes through unchanged", + expression={"$toDecimal": DECIMAL128_NEGATIVE_INFINITY}, + expected=DECIMAL128_NEGATIVE_INFINITY, + ), + ExpressionTestCase( + "dec128_high_precision", + msg="Decimal128 maximum 34-digit coefficient passes through at full precision", + expression={"$toDecimal": DECIMAL128_MAX_COEFFICIENT}, + expected=DECIMAL128_MAX_COEFFICIENT, + ), +] + + +@pytest.mark.parametrize("test", pytest_params(TODECIMAL_DECIMAL128_TESTS)) +def test_toDecimal_decimal128(collection, test: ExpressionTestCase): + """$toDecimal is the identity function for Decimal128 inputs.""" + result = execute_expression(collection, test.expression) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/test_toDecimal_double.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/test_toDecimal_double.py new file mode 100644 index 000000000..6705b327b --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/test_toDecimal_double.py @@ -0,0 +1,147 @@ +"""$toDecimal double conversion tests: 15 significant digits, zero, NaN, and infinity.""" + +import pytest +from bson import Decimal128 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ZERO, + DOUBLE_MAX, + DOUBLE_MAX_SAFE_INTEGER, + DOUBLE_MIN, + DOUBLE_MIN_NEGATIVE_SUBNORMAL, + DOUBLE_MIN_SUBNORMAL, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_TWO_AND_HALF, + DOUBLE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, +) + +# Property [Double Conversion]: non-zero finite doubles produce exactly 15 significant digits; +# zero, negative zero, NaN, and infinity have special mappings. +TODECIMAL_DOUBLE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "double_two_point_five", + msg="2.5 converts with 15 significant digits", + expression={"$toDecimal": DOUBLE_TWO_AND_HALF}, + expected=Decimal128("2.50000000000000"), + ), + ExpressionTestCase( + "double_negative", + msg="Negative double converts with 15 significant digits", + expression={"$toDecimal": -DOUBLE_TWO_AND_HALF}, + expected=Decimal128("-2.50000000000000"), + ), + ExpressionTestCase( + "double_zero", + msg="0.0 converts to Decimal128('0') with no trailing zeros", + expression={"$toDecimal": DOUBLE_ZERO}, + expected=DECIMAL128_ZERO, + ), + ExpressionTestCase( + "double_negative_zero", + msg="Negative zero double preserves sign", + expression={"$toDecimal": DOUBLE_NEGATIVE_ZERO}, + expected=DECIMAL128_NEGATIVE_ZERO, + ), + ExpressionTestCase( + "double_nan", + msg="double NaN converts to Decimal128 NaN", + expression={"$toDecimal": FLOAT_NAN}, + expected=DECIMAL128_NAN, + ), + ExpressionTestCase( + "double_pos_inf", + msg="double Infinity converts to Decimal128 Infinity", + expression={"$toDecimal": FLOAT_INFINITY}, + expected=DECIMAL128_INFINITY, + ), + ExpressionTestCase( + "double_neg_inf", + msg="double -Infinity converts to Decimal128 -Infinity", + expression={"$toDecimal": FLOAT_NEGATIVE_INFINITY}, + expected=DECIMAL128_NEGATIVE_INFINITY, + ), + ExpressionTestCase( + "double_round_up_power_of_10", + msg="Double rounds up to next power of 10 at 15th digit boundary", + expression={"$toDecimal": 9.999999999999998e15}, + expected=Decimal128("1.00000000000000E+16"), + ), + ExpressionTestCase( + "double_round_up", + msg="Double rounds up at 15th significant digit", + expression={"$toDecimal": 1.234567890123456}, + expected=Decimal128("1.23456789012346"), + ), + ExpressionTestCase( + "double_round_misleading_literal", + msg="Double rounds based on stored imprecise value, not source literal", + expression={"$toDecimal": 1.234567890123455}, + expected=Decimal128("1.23456789012345"), + ), + ExpressionTestCase( + "double_round_down", + msg="Double rounds down at 15th significant digit", + expression={"$toDecimal": 1.234567890123451}, + expected=Decimal128("1.23456789012345"), + ), + ExpressionTestCase( + "double_round_to_one", + msg="Double 0.9999999999999996 rounds to 1.00000000000000", + expression={"$toDecimal": 0.9999999999999996}, + expected=Decimal128("1.00000000000000"), + ), + ExpressionTestCase( + "double_max_safe_int", + msg="Max safe integer (2^53) converts with 15 significant digits", + expression={"$toDecimal": float(DOUBLE_MAX_SAFE_INTEGER)}, + expected=Decimal128("9.00719925474099E+15"), + ), + ExpressionTestCase( + "double_subnormal", + msg="Minimum positive subnormal double converts with 15 significant digits", + expression={"$toDecimal": DOUBLE_MIN_SUBNORMAL}, + expected=Decimal128("4.94065645841247E-324"), + ), + ExpressionTestCase( + "double_negative_subnormal", + msg="Minimum negative subnormal double converts with 15 significant digits", + expression={"$toDecimal": DOUBLE_MIN_NEGATIVE_SUBNORMAL}, + expected=Decimal128("-4.94065645841247E-324"), + ), + ExpressionTestCase( + "double_near_max", + msg="Near-max double converts with 15 significant digits", + expression={"$toDecimal": DOUBLE_MAX}, + expected=Decimal128("1.79769313486232E+308"), + ), + ExpressionTestCase( + "double_near_min", + msg="Near-min double converts with 15 significant digits", + expression={"$toDecimal": DOUBLE_MIN}, + expected=Decimal128("-1.79769313486232E+308"), + ), +] + + +@pytest.mark.parametrize("test", pytest_params(TODECIMAL_DOUBLE_TESTS)) +def test_toDecimal_double(collection, test: ExpressionTestCase): + """$toDecimal converts double inputs to Decimal128 with 15 significant digits.""" + result = execute_expression(collection, test.expression) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/test_toDecimal_field_ref.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/test_toDecimal_field_ref.py new file mode 100644 index 000000000..bd9e63e78 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/test_toDecimal_field_ref.py @@ -0,0 +1,148 @@ +"""$toDecimal field reference and expression-as-input tests.""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import CONVERSION_FAILURE_ERROR +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_TWO_AND_HALF, + DECIMAL128_ZERO, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_TWO_AND_HALF, + INT32_ZERO, +) + +# Property [Field Reference]: $toDecimal resolves field paths and nested dot-notation paths; +# missing fields and missing nested fields return null. +TODECIMAL_FIELD_REF_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int_field", + msg="int32 field converts to Decimal128", + expression={"$toDecimal": "$v"}, + doc={"v": 42}, + expected=Decimal128("42"), + ), + ExpressionTestCase( + "int64_field", + msg="int64 field converts to Decimal128", + expression={"$toDecimal": "$v"}, + doc={"v": Int64(86400000)}, + expected=Decimal128("86400000"), + ), + ExpressionTestCase( + "decimal_field", + msg="Decimal128 field passes through unchanged", + expression={"$toDecimal": "$v"}, + doc={"v": Decimal128("3.14")}, + expected=Decimal128("3.14"), + ), + ExpressionTestCase( + "string_field", + msg="Numeric string field converts to Decimal128", + expression={"$toDecimal": "$v"}, + doc={"v": "2.5"}, + expected=DECIMAL128_TWO_AND_HALF, + ), + ExpressionTestCase( + "bool_field", + msg="Bool field converts to Decimal128", + expression={"$toDecimal": "$v"}, + doc={"v": True}, + expected=Decimal128("1"), + ), + ExpressionTestCase( + "double_field", + msg="Double field converts to Decimal128 with 15 significant digits", + expression={"$toDecimal": "$v"}, + doc={"v": DOUBLE_TWO_AND_HALF}, + expected=Decimal128("2.50000000000000"), + ), + ExpressionTestCase( + "nested_field", + msg="Nested field path converts to Decimal128", + expression={"$toDecimal": "$doc.v"}, + doc={"doc": {"v": 100}}, + expected=Decimal128("100"), + ), + ExpressionTestCase( + "missing_nested", + msg="Missing nested field returns null", + expression={"$toDecimal": "$doc.missing"}, + doc={"doc": {"x": 1}}, + expected=None, + ), + ExpressionTestCase( + "missing_field", + msg="Missing top-level field returns null", + expression={"$toDecimal": "$v"}, + doc={}, + expected=None, + ), + ExpressionTestCase( + "neg_zero_double_field", + msg="$toDecimal preserves -0.0 sign when reading double from a document field", + expression={"$toDecimal": "$v"}, + doc={"v": DOUBLE_NEGATIVE_ZERO}, + expected=DECIMAL128_NEGATIVE_ZERO, + ), + ExpressionTestCase( + "zero_field", + msg="$toDecimal converts zero from a document field", + expression={"$toDecimal": "$v"}, + doc={"v": INT32_ZERO}, + expected=DECIMAL128_ZERO, + ), + ExpressionTestCase( + "composite_array_path", + msg="$toDecimal fails when field path resolves to a composite array", + expression={"$toDecimal": "$a.b"}, + doc={"a": [{"b": 1}, {"b": 2}]}, + error_code=CONVERSION_FAILURE_ERROR, + ), +] + +# Property [Expression Input]: $toDecimal evaluates a nested expression before converting. +TODECIMAL_EXPRESSION_INPUT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "add", + msg="$toDecimal converts an arithmetic expression result", + expression={"$toDecimal": {"$add": [10, 20]}}, + expected=Decimal128("30"), + ), + ExpressionTestCase( + "concat", + msg="$toDecimal converts a string concatenation expression result", + expression={"$toDecimal": {"$concat": ["3", ".", "14"]}}, + expected=Decimal128("3.14"), + ), + ExpressionTestCase( + "nested_conversion", + msg="$toDecimal converts a nested type-conversion expression result", + expression={"$toDecimal": {"$toDouble": 42}}, + expected=Decimal128("4.20000000000000E+1"), + ), +] + + +@pytest.mark.parametrize( + "test", pytest_params(TODECIMAL_FIELD_REF_TESTS + TODECIMAL_EXPRESSION_INPUT_TESTS) +) +def test_toDecimal_field_ref(collection, test: ExpressionTestCase): + """$toDecimal resolves field paths and nested paths from inserted documents.""" + if test.doc is not None: + result = execute_expression_with_insert(collection, test.expression, test.doc) + else: + result = execute_expression(collection, test.expression) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/test_toDecimal_numeric.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/test_toDecimal_numeric.py new file mode 100644 index 000000000..0a5624b30 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/test_toDecimal_numeric.py @@ -0,0 +1,182 @@ +"""$toDecimal numeric type tests: null/missing, boolean, int32, and int64.""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INT64_MAX, + DECIMAL128_ZERO, + INT32_MAX, + INT32_MAX_MINUS_1, + INT32_MIN, + INT32_MIN_PLUS_1, + INT32_OVERFLOW, + INT32_UNDERFLOW, + INT32_ZERO, + INT64_MAX, + INT64_MAX_MINUS_1, + INT64_MIN, + INT64_MIN_PLUS_1, + INT64_ZERO, + MISSING, +) + +# Property [Null and Missing]: $toDecimal returns null for null and missing inputs. +TODECIMAL_NULL_MISSING_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null", + msg="Should return null for null", + expression={"$toDecimal": None}, + expected=None, + ), + ExpressionTestCase( + "missing", + msg="Should return null for missing", + expression={"$toDecimal": MISSING}, + expected=None, + ), +] + +# Property [Boolean]: $toDecimal converts true to Decimal128("1") and false to Decimal128("0"). +TODECIMAL_BOOL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "bool_true", + msg="True converts to Decimal128('1')", + expression={"$toDecimal": True}, + expected=Decimal128("1"), + ), + ExpressionTestCase( + "bool_false", + msg="False converts to Decimal128('0')", + expression={"$toDecimal": False}, + expected=DECIMAL128_ZERO, + ), +] + +# Property [Int32]: $toDecimal converts int32 values to exact Decimal128 with no trailing zeros. +TODECIMAL_INT32_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int32_zero", + msg="int32 zero converts to Decimal128('0')", + expression={"$toDecimal": INT32_ZERO}, + expected=DECIMAL128_ZERO, + ), + ExpressionTestCase( + "int32_one", + msg="int32 1 converts to Decimal128('1')", + expression={"$toDecimal": 1}, + expected=Decimal128("1"), + ), + ExpressionTestCase( + "int32_neg_one", + msg="int32 -1 converts to Decimal128('-1')", + expression={"$toDecimal": -1}, + expected=Decimal128("-1"), + ), + ExpressionTestCase( + "int32_max", + msg="int32 max converts exactly", + expression={"$toDecimal": INT32_MAX}, + expected=Decimal128(str(INT32_MAX)), + ), + ExpressionTestCase( + "int32_min", + msg="int32 min converts exactly", + expression={"$toDecimal": INT32_MIN}, + expected=Decimal128(str(INT32_MIN)), + ), + ExpressionTestCase( + "int32_max_minus_1", + msg="int32 max-1 converts exactly", + expression={"$toDecimal": INT32_MAX_MINUS_1}, + expected=Decimal128(str(INT32_MAX_MINUS_1)), + ), + ExpressionTestCase( + "int32_min_plus_1", + msg="int32 min+1 converts exactly", + expression={"$toDecimal": INT32_MIN_PLUS_1}, + expected=Decimal128(str(INT32_MIN_PLUS_1)), + ), +] + +# Property [Int64]: $toDecimal converts int64 values to exact Decimal128 with no trailing zeros. +TODECIMAL_INT64_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int64_zero", + msg="int64 zero converts to Decimal128('0')", + expression={"$toDecimal": INT64_ZERO}, + expected=DECIMAL128_ZERO, + ), + ExpressionTestCase( + "int64_one", + msg="int64 1 converts to Decimal128('1')", + expression={"$toDecimal": Int64(1)}, + expected=Decimal128("1"), + ), + ExpressionTestCase( + "int64_neg_one", + msg="int64 -1 converts to Decimal128('-1')", + expression={"$toDecimal": Int64(-1)}, + expected=Decimal128("-1"), + ), + ExpressionTestCase( + "int64_beyond_int32_pos", + msg="int64 just beyond int32 max converts exactly", + expression={"$toDecimal": INT32_OVERFLOW}, + expected=Decimal128(str(INT32_OVERFLOW)), + ), + ExpressionTestCase( + "int64_beyond_int32_neg", + msg="int64 just beyond int32 min converts exactly", + expression={"$toDecimal": INT32_UNDERFLOW}, + expected=Decimal128(str(INT32_UNDERFLOW)), + ), + ExpressionTestCase( + "int64_max", + msg="int64 max converts exactly to Decimal128", + expression={"$toDecimal": INT64_MAX}, + expected=DECIMAL128_INT64_MAX, + ), + ExpressionTestCase( + "int64_min", + msg="int64 min converts exactly to Decimal128", + expression={"$toDecimal": INT64_MIN}, + expected=Decimal128(str(INT64_MIN)), + ), + ExpressionTestCase( + "int64_max_minus_1", + msg="int64 max-1 converts exactly", + expression={"$toDecimal": INT64_MAX_MINUS_1}, + expected=Decimal128(str(INT64_MAX_MINUS_1)), + ), + ExpressionTestCase( + "int64_min_plus_1", + msg="int64 min+1 converts exactly", + expression={"$toDecimal": INT64_MIN_PLUS_1}, + expected=Decimal128(str(INT64_MIN_PLUS_1)), + ), +] + +TODECIMAL_NUMERIC_TESTS = ( + TODECIMAL_NULL_MISSING_TESTS + + TODECIMAL_BOOL_TESTS + + TODECIMAL_INT32_TESTS + + TODECIMAL_INT64_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(TODECIMAL_NUMERIC_TESTS)) +def test_toDecimal_numeric(collection, test: ExpressionTestCase): + """$toDecimal converts null, missing, bool, int32, and int64 inputs.""" + result = execute_expression(collection, test.expression) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/test_toDecimal_return_type.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/test_toDecimal_return_type.py new file mode 100644 index 000000000..83a60d359 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/test_toDecimal_return_type.py @@ -0,0 +1,131 @@ +"""$toDecimal return-type invariant and idempotency tests.""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, +) +from documentdb_tests.framework.bson_type_validator import ( + BsonType, + BsonTypeTestCase, + generate_bson_acceptance_test_cases, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_HALF, + DECIMAL128_ZERO, + DOUBLE_TWO_AND_HALF, + MISSING, +) + +# Property [Return Type]: $toDecimal always returns BSON type 'decimal' for successful +# conversions and null for null or missing inputs. +RETURN_TYPE_DECIMAL_SPEC = [ + BsonTypeTestCase( + id="toDecimal_return_type", + msg="$toDecimal always returns BSON type decimal for a successful conversion", + valid_types=[ + BsonType.BOOL, + BsonType.DOUBLE, + BsonType.INT, + BsonType.LONG, + BsonType.DECIMAL, + BsonType.STRING, + BsonType.DATE, + ], + valid_inputs={ + BsonType.STRING: "7.5", + }, + ), +] + +RETURN_TYPE_DECIMAL_CASES = generate_bson_acceptance_test_cases(RETURN_TYPE_DECIMAL_SPEC) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", RETURN_TYPE_DECIMAL_CASES) +def test_toDecimal_return_type_is_decimal(collection, bson_type, sample_value, spec): + """$toDecimal always returns BSON type 'decimal' for a successful conversion.""" + result = execute_expression(collection, {"$type": {"$toDecimal": sample_value}}) + assert_expression_result( + result, expected="decimal", msg=f"{spec.msg} ({bson_type.value} input)" + ) + + +# Property [Return Type — Null]: $toDecimal returns BSON type null for null or missing inputs. +TODECIMAL_RETURN_TYPE_NULL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "return_type_null", + msg="$toDecimal of null returns null type", + expression={"$type": {"$toDecimal": None}}, + expected="null", + ), + ExpressionTestCase( + "return_type_missing", + msg="$toDecimal of missing returns null type", + expression={"$type": {"$toDecimal": MISSING}}, + expected="null", + ), +] + + +# Property [Idempotency]: applying $toDecimal twice produces the same result as once. +TODECIMAL_IDEMPOTENCY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "idempotent_bool", + msg="$toDecimal is idempotent for bool", + expression={"$toDecimal": {"$toDecimal": True}}, + expected=Decimal128("1"), + ), + ExpressionTestCase( + "idempotent_int32", + msg="$toDecimal is idempotent for int32", + expression={"$toDecimal": {"$toDecimal": 42}}, + expected=Decimal128("42"), + ), + ExpressionTestCase( + "idempotent_int64", + msg="$toDecimal is idempotent for int64", + expression={"$toDecimal": {"$toDecimal": Int64(99)}}, + expected=Decimal128("99"), + ), + ExpressionTestCase( + "idempotent_decimal128", + msg="$toDecimal is idempotent for Decimal128", + expression={"$toDecimal": {"$toDecimal": DECIMAL128_HALF}}, + expected=DECIMAL128_HALF, + ), + ExpressionTestCase( + "idempotent_zero", + msg="$toDecimal is idempotent for zero", + expression={"$toDecimal": {"$toDecimal": DECIMAL128_ZERO}}, + expected=DECIMAL128_ZERO, + ), + ExpressionTestCase( + "idempotent_double", + msg="$toDecimal is idempotent for double", + expression={"$toDecimal": {"$toDecimal": DOUBLE_TWO_AND_HALF}}, + expected=Decimal128("2.50000000000000"), + ), + ExpressionTestCase( + "idempotent_string", + msg="$toDecimal is idempotent for numeric string", + expression={"$toDecimal": {"$toDecimal": "7.5"}}, + expected=Decimal128("7.5"), + ), +] + + +@pytest.mark.parametrize( + "test", pytest_params(TODECIMAL_RETURN_TYPE_NULL_TESTS + TODECIMAL_IDEMPOTENCY_TESTS) +) +def test_toDecimal_return_type_null(collection, test: ExpressionTestCase): + """$toDecimal returns BSON type 'null' for null or missing input.""" + result = execute_expression(collection, test.expression) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/test_toDecimal_string.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/test_toDecimal_string.py new file mode 100644 index 000000000..2639dea6d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/test_toDecimal_string.py @@ -0,0 +1,225 @@ +"""$toDecimal valid string conversion tests: decimal, scientific notation, and special values.""" + +import pytest +from bson import Decimal128 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, +) +from documentdb_tests.framework.lazy_payload import lazy +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_HALF, + DECIMAL128_INFINITY, + DECIMAL128_MIN_POSITIVE, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ZERO, + STRING_SIZE_LIMIT_BYTES, +) + +# Property [String Conversion — Valid]: base-10 numeric strings, scientific notation, and +# special value strings convert to the corresponding Decimal128. +TODECIMAL_STRING_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "str_negative_decimal", + msg="$toDecimal should convert negative decimal string", + expression={"$toDecimal": "-5.5"}, + expected=Decimal128("-5.5"), + ), + ExpressionTestCase( + "str_scientific_upper", + msg="$toDecimal should accept uppercase E in scientific notation", + expression={"$toDecimal": "1.5E+3"}, + expected=Decimal128("1.5E+3"), + ), + ExpressionTestCase( + "str_scientific_lower", + msg="$toDecimal should accept lowercase e in scientific notation", + expression={"$toDecimal": "1.5e+3"}, + expected=Decimal128("1.5E+3"), + ), + ExpressionTestCase( + "str_leading_plus", + msg="$toDecimal should strip leading plus sign", + expression={"$toDecimal": "+123"}, + expected=Decimal128("123"), + ), + ExpressionTestCase( + "str_leading_zeros", + msg="$toDecimal should strip leading zeros", + expression={"$toDecimal": "007"}, + expected=Decimal128("7"), + ), + ExpressionTestCase( + "str_leading_decimal", + msg="$toDecimal should accept leading decimal point", + expression={"$toDecimal": ".5"}, + expected=DECIMAL128_HALF, + ), + ExpressionTestCase( + "str_trailing_decimal", + msg="$toDecimal should accept trailing decimal point", + expression={"$toDecimal": "5."}, + expected=Decimal128("5"), + ), + ExpressionTestCase( + "str_trailing_zeros_preserved", + msg="$toDecimal should preserve trailing zeros in string decimal part", + expression={"$toDecimal": "1.00"}, + expected=Decimal128("1.00"), + ), + ExpressionTestCase( + "str_nan_mixed_case", + msg="$toDecimal should accept 'NaN'", + expression={"$toDecimal": "NaN"}, + expected=DECIMAL128_NAN, + ), + ExpressionTestCase( + "str_nan_lower", + msg="$toDecimal should accept 'nan' case-insensitively", + expression={"$toDecimal": "nan"}, + expected=DECIMAL128_NAN, + ), + ExpressionTestCase( + "str_nan_upper", + msg="$toDecimal should accept 'NAN' case-insensitively", + expression={"$toDecimal": "NAN"}, + expected=DECIMAL128_NAN, + ), + ExpressionTestCase( + "str_infinity", + msg="$toDecimal should accept 'Infinity'", + expression={"$toDecimal": "Infinity"}, + expected=DECIMAL128_INFINITY, + ), + ExpressionTestCase( + "str_inf_short", + msg="$toDecimal should accept 'inf' short form", + expression={"$toDecimal": "inf"}, + expected=DECIMAL128_INFINITY, + ), + ExpressionTestCase( + "str_inf_upper", + msg="$toDecimal should accept 'INF' case-insensitively", + expression={"$toDecimal": "INF"}, + expected=DECIMAL128_INFINITY, + ), + ExpressionTestCase( + "str_neg_nan", + msg="$toDecimal should convert '-NaN' string preserving sign bit", + expression={"$toDecimal": "-NaN"}, + expected=Decimal128("-NaN"), + ), + ExpressionTestCase( + "str_neg_infinity", + msg="$toDecimal should preserve sign on negative Infinity string", + expression={"$toDecimal": "-Infinity"}, + expected=DECIMAL128_NEGATIVE_INFINITY, + ), + ExpressionTestCase( + "str_plus_infinity", + msg="$toDecimal should strip plus sign from Infinity string", + expression={"$toDecimal": "+Infinity"}, + expected=DECIMAL128_INFINITY, + ), + ExpressionTestCase( + "str_max_exponent", + msg="$toDecimal should accept maximum exponent string", + expression={"$toDecimal": "1E+6144"}, + expected=Decimal128("1.000000000000000000000000000000000E+6144"), + ), + ExpressionTestCase( + "str_min_exponent", + msg="$toDecimal should accept minimum exponent string", + expression={"$toDecimal": "1E-6176"}, + expected=DECIMAL128_MIN_POSITIVE, + ), + ExpressionTestCase( + "str_exactly_34_digits", + msg="$toDecimal should convert 34-digit string without rounding", + expression={"$toDecimal": "1234567890123456789012345678901234"}, + expected=Decimal128("1234567890123456789012345678901234"), + ), + ExpressionTestCase( + "str_exceeds_34_digits", + msg="$toDecimal should round strings exceeding 34 significant digits", + expression={"$toDecimal": "12345678901234567890123456789012345"}, + expected=Decimal128("1.234567890123456789012345678901234E+34"), + ), + ExpressionTestCase( + "str_negative_zero", + msg="$toDecimal should preserve negative zero from string", + expression={"$toDecimal": "-0"}, + expected=DECIMAL128_NEGATIVE_ZERO, + ), + ExpressionTestCase( + "str_plus_zero", + msg="$toDecimal should convert '+0' to Decimal128 zero", + expression={"$toDecimal": "+0"}, + expected=DECIMAL128_ZERO, + ), + ExpressionTestCase( + "str_plus_dot_zero", + msg="$toDecimal should convert '+.0' to Decimal128 zero", + expression={"$toDecimal": "+.0"}, + expected=Decimal128("0.0"), + ), + ExpressionTestCase( + "str_neg_zero_neg_exp", + msg="$toDecimal should convert '-0E-1' preserving negative zero", + expression={"$toDecimal": "-0E-1"}, + expected=Decimal128("-0.0"), + ), + ExpressionTestCase( + "str_zero_pos_exp", + msg="$toDecimal should convert '0E+1' preserving exponent", + expression={"$toDecimal": "0E+1"}, + expected=Decimal128("0E+1"), + ), + ExpressionTestCase( + "str_shifted_decimal_large", + msg="$toDecimal should convert shifted decimal form of large value", + expression={"$toDecimal": ".1797693134862315807E+309"}, + expected=Decimal128("1.797693134862315807E+308"), + ), + ExpressionTestCase( + "str_plus_prefixed_max", + msg="$toDecimal should strip plus sign from max-range string", + expression={"$toDecimal": "+1.797693134862315807E+308"}, + expected=Decimal128("1.797693134862315807E+308"), + ), + ExpressionTestCase( + "str_rounding_34_digits", + msg="$toDecimal rounds at digit 35; digit-35 is 0 so result rounds down", + expression={"$toDecimal": "1.00000000000000000000000000000000099999"}, + expected=Decimal128("1.000000000000000000000000000000000"), + ), +] + + +# Property [String Size Limit — Valid]: a valid numeric string one byte under the limit converts. +TODECIMAL_STRING_SIZE_LIMIT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "size_one_under_valid", + msg="Valid numeric string one byte under limit converts successfully", + expression=lazy(lambda: {"$toDecimal": "0" * (STRING_SIZE_LIMIT_BYTES - 2) + "1"}), + expected=Decimal128("1"), + ), +] + + +@pytest.mark.parametrize( + "test", pytest_params(TODECIMAL_STRING_TESTS + TODECIMAL_STRING_SIZE_LIMIT_TESTS) +) +def test_toDecimal_string(collection, test: ExpressionTestCase): + """$toDecimal parses valid numeric strings including scientific notation and special values.""" + result = execute_expression(collection, test.expression) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/test_toDecimal_string_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/test_toDecimal_string_errors.py new file mode 100644 index 000000000..efe965418 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/test_toDecimal_string_errors.py @@ -0,0 +1,228 @@ +"""$toDecimal string parsing error tests: invalid formats and size limit rejections.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, +) +from documentdb_tests.framework.error_codes import CONVERSION_FAILURE_ERROR, STRING_SIZE_LIMIT_ERROR +from documentdb_tests.framework.lazy_payload import lazy +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import STRING_SIZE_LIMIT_BYTES + +# Property [String Parsing Errors]: invalid string formats produce CONVERSION_FAILURE_ERROR. +TODECIMAL_STRING_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "str_err_empty", + msg="$toDecimal should reject empty string", + expression={"$toDecimal": ""}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "str_err_non_numeric", + msg="$toDecimal should reject non-numeric string", + expression={"$toDecimal": "h"}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "str_err_bare_plus", + msg="$toDecimal should reject bare plus sign", + expression={"$toDecimal": "+"}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "str_err_bare_minus", + msg="$toDecimal should reject bare minus sign", + expression={"$toDecimal": "-"}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "str_err_bare_dot", + msg="$toDecimal should reject bare dot", + expression={"$toDecimal": "."}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "str_err_hex", + msg="$toDecimal should reject hexadecimal string", + expression={"$toDecimal": "0x4301"}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "str_err_leading_space", + msg="$toDecimal should reject string with leading space", + expression={"$toDecimal": " 1"}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "str_err_trailing_space", + msg="$toDecimal should reject string with trailing space", + expression={"$toDecimal": "1 "}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "str_err_embedded_space", + msg="$toDecimal should reject string with embedded space", + expression={"$toDecimal": "1 2"}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "str_err_tab", + msg="$toDecimal should reject string with tab", + expression={"$toDecimal": "\t1"}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "str_err_newline", + msg="$toDecimal should reject string with newline", + expression={"$toDecimal": "\n1"}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "str_err_cr", + msg="$toDecimal should reject string with carriage return", + expression={"$toDecimal": "\r1"}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "str_err_nbsp", + msg="$toDecimal should reject string with non-breaking space (U+00A0)", + expression={"$toDecimal": "\u00a01"}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "str_err_en_space", + msg="$toDecimal should reject string with en space (U+2002)", + expression={"$toDecimal": "\u20021"}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "str_err_bom", + msg="$toDecimal should reject string with BOM (U+FEFF)", + expression={"$toDecimal": "\ufeff1"}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "str_err_zwsp", + msg="$toDecimal should reject string with zero-width space (U+200B)", + expression={"$toDecimal": "\u200b1"}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "str_err_zwj", + msg="$toDecimal should reject string with zero-width joiner (U+200D)", + expression={"$toDecimal": "\u200d1"}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "str_err_rtl_mark", + msg="$toDecimal should reject string with right-to-left mark (U+200F)", + expression={"$toDecimal": "\u200f1"}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "str_err_incomplete_exp", + msg="$toDecimal should reject incomplete exponent", + expression={"$toDecimal": "1E"}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "str_err_incomplete_exp_plus", + msg="$toDecimal should reject incomplete exponent with plus", + expression={"$toDecimal": "1E+"}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "str_err_incomplete_exp_minus", + msg="$toDecimal should reject incomplete exponent with minus", + expression={"$toDecimal": "1E-"}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "str_err_multi_decimal", + msg="$toDecimal should reject multiple decimal points", + expression={"$toDecimal": "1.2.3"}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "str_err_multi_exponent", + msg="$toDecimal should reject multiple exponents", + expression={"$toDecimal": "1E2E3"}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "str_err_underscore", + msg="$toDecimal should reject underscore digit separators", + expression={"$toDecimal": "1_000"}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "str_err_comma", + msg="$toDecimal should reject comma digit separators", + expression={"$toDecimal": "1,000"}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "str_err_null_byte", + msg="$toDecimal should reject string with null byte", + expression={"$toDecimal": "\x001"}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "str_err_non_ascii_digit", + msg="$toDecimal should reject non-ASCII digit characters (U+0661)", + expression={"$toDecimal": "\u0661"}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "str_err_overflow", + msg="$toDecimal should reject exponent overflow", + expression={"$toDecimal": "1E+6145"}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "str_err_underflow", + msg="$toDecimal should reject exponent underflow", + expression={"$toDecimal": "1E-6177"}, + error_code=CONVERSION_FAILURE_ERROR, + ), +] + + +# Property [String Size Limit — Errors]: strings at or above the size limit are rejected with +# STRING_SIZE_LIMIT_ERROR; non-numeric strings just under the limit fail with CONVERSION_FAILURE. +TODECIMAL_STRING_SIZE_LIMIT_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "size_one_under_non_numeric", + msg="Non-numeric string one byte under limit passes size check but fails conversion", + expression=lazy(lambda: {"$toDecimal": "a" * (STRING_SIZE_LIMIT_BYTES - 1)}), + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "size_at_limit", + msg="String at STRING_SIZE_LIMIT_BYTES is rejected with STRING_SIZE_LIMIT_ERROR", + expression=lazy(lambda: {"$toDecimal": "0" * STRING_SIZE_LIMIT_BYTES}), + error_code=STRING_SIZE_LIMIT_ERROR, + ), + ExpressionTestCase( + "size_at_limit_four_byte", + msg="4-byte character string reaching STRING_SIZE_LIMIT_BYTES is rejected", + expression=lazy(lambda: {"$toDecimal": "\U0001f600" * (STRING_SIZE_LIMIT_BYTES // 4)}), + error_code=STRING_SIZE_LIMIT_ERROR, + ), +] + + +@pytest.mark.parametrize( + "test", pytest_params(TODECIMAL_STRING_ERROR_TESTS + TODECIMAL_STRING_SIZE_LIMIT_ERROR_TESTS) +) +def test_toDecimal_string_errors(collection, test: ExpressionTestCase): + """$toDecimal rejects malformed and out-of-range string inputs.""" + result = execute_expression(collection, test.expression) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDouble/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDouble/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDouble/test_toDouble_arity.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDouble/test_toDouble_arity.py new file mode 100644 index 000000000..f076cc22b --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDouble/test_toDouble_arity.py @@ -0,0 +1,85 @@ +"""$toDouble arity and field path syntax tests.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, +) +from documentdb_tests.framework.error_codes import ( + FAILED_TO_PARSE_ERROR, + INVALID_DOLLAR_FIELD_PATH, + TO_TYPE_ARITY_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Arity]: $toDouble unwraps single-element literal arrays and rejects empty or +# multi-element arrays. These cases are specific to $toDouble syntax (not $convert). +TODOUBLE_ARITY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "single_element", + msg="Single-element literal array argument is unwrapped to a scalar", + expression={"$toDouble": [42]}, + expected=42.0, + ), + ExpressionTestCase( + "empty_array", + msg="Empty literal array argument is an arity error", + expression={"$toDouble": []}, + error_code=TO_TYPE_ARITY_ERROR, + ), + ExpressionTestCase( + "multi_element", + msg="Multi-element literal array argument is an arity error", + expression={"$toDouble": [1, 2]}, + error_code=TO_TYPE_ARITY_ERROR, + ), + ExpressionTestCase( + "single_null", + msg="Single-element literal array wrapping null unwraps and returns null", + expression={"$toDouble": [None]}, + expected=None, + ), + ExpressionTestCase( + "single_bool", + msg="Single-element literal array wrapping a bool unwraps and converts", + expression={"$toDouble": [True]}, + expected=1.0, + ), + ExpressionTestCase( + "large_array", + msg="A large literal array argument is an arity error", + expression={"$toDouble": list(range(100))}, + error_code=TO_TYPE_ARITY_ERROR, + ), +] + +# Property [Invalid Field Path]: $toDouble rejects malformed field path syntax. +TODOUBLE_INVALID_FIELD_PATH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "bare_dollar", + msg="Bare '$' is an invalid field path", + expression={"$toDouble": "$"}, + error_code=INVALID_DOLLAR_FIELD_PATH, + ), + ExpressionTestCase( + "double_dollar", + msg="'$$' is rejected as an empty variable name", + expression={"$toDouble": "$$"}, + error_code=FAILED_TO_PARSE_ERROR, + ), +] + + +@pytest.mark.parametrize( + "test", pytest_params(TODOUBLE_ARITY_TESTS + TODOUBLE_INVALID_FIELD_PATH_TESTS) +) +def test_toDouble_arity(collection, test: ExpressionTestCase): + """$toDouble literal array arguments are unwrapped or rejected based on arity.""" + result = execute_expression(collection, test.expression) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDouble/test_toDouble_datetime_binary.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDouble/test_toDouble_datetime_binary.py new file mode 100644 index 000000000..ef401a048 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDouble/test_toDouble_datetime_binary.py @@ -0,0 +1,212 @@ +"""$toDouble datetime and binary conversion tests, and unsupported BSON type errors.""" + +import struct +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, +) +from documentdb_tests.framework.error_codes import CONVERSION_FAILURE_ERROR +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DATE_BEFORE_EPOCH, + DATE_EPOCH, + DOUBLE_ONE_AND_HALF, + DOUBLE_ZERO, +) + +# Pre-computed binary values: 4-byte little-endian float32. +_f32_zero = Binary(struct.pack(" Date: Wed, 15 Jul 2026 14:04:54 -0700 Subject: [PATCH 29/35] Add $dayOfWeek, $dayOfMonth, $dayOfYear tests (#681) Signed-off-by: Daniel Frankcom Co-authored-by: Daniel Frankcom Co-authored-by: Mitchell Elholm Signed-off-by: RyanGarfinkel <113050972+RyanGarfinkel@users.noreply.github.com> --- .../expressions/date/dayOfMonth/__init__.py | 0 .../dayOfMonth/test_dayOfMonth_calendar.py | 230 ++++++++++++++++ .../dayOfMonth/test_dayOfMonth_date_types.py | 187 +++++++++++++ .../dayOfMonth/test_dayOfMonth_expressions.py | 160 +++++++++++ .../test_dayOfMonth_null_and_type_errors.py | 211 ++++++++++++++ .../test_dayOfMonth_timezone_input_types.py | 226 +++++++++++++++ .../test_dayOfMonth_timezone_names.py | 248 +++++++++++++++++ .../test_dayOfMonth_timezone_offsets.py | 259 +++++++++++++++++ .../test_dayOfMonth_timezone_validation.py | 151 ++++++++++ .../expressions/date/dayOfWeek/__init__.py | 0 .../date/dayOfWeek/test_dayOfWeek_calendar.py | 238 ++++++++++++++++ .../dayOfWeek/test_dayOfWeek_date_types.py | 187 +++++++++++++ .../dayOfWeek/test_dayOfWeek_expressions.py | 160 +++++++++++ .../test_dayOfWeek_null_and_type_errors.py | 211 ++++++++++++++ .../test_dayOfWeek_timezone_input_types.py | 224 +++++++++++++++ .../test_dayOfWeek_timezone_names.py | 237 ++++++++++++++++ .../test_dayOfWeek_timezone_offsets.py | 260 ++++++++++++++++++ .../test_dayOfWeek_timezone_validation.py | 151 ++++++++++ .../expressions/date/dayOfYear/__init__.py | 0 .../date/dayOfYear/test_dayOfYear_calendar.py | 223 +++++++++++++++ .../dayOfYear/test_dayOfYear_date_types.py | 187 +++++++++++++ .../dayOfYear/test_dayOfYear_expressions.py | 160 +++++++++++ .../test_dayOfYear_null_and_type_errors.py | 211 ++++++++++++++ .../test_dayOfYear_timezone_input_types.py | 224 +++++++++++++++ .../test_dayOfYear_timezone_names.py | 237 ++++++++++++++++ .../test_dayOfYear_timezone_offsets.py | 259 +++++++++++++++++ .../test_dayOfYear_timezone_validation.py | 151 ++++++++++ 27 files changed, 4992 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfMonth/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfMonth/test_dayOfMonth_calendar.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfMonth/test_dayOfMonth_date_types.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfMonth/test_dayOfMonth_expressions.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfMonth/test_dayOfMonth_null_and_type_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfMonth/test_dayOfMonth_timezone_input_types.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfMonth/test_dayOfMonth_timezone_names.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfMonth/test_dayOfMonth_timezone_offsets.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfMonth/test_dayOfMonth_timezone_validation.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfWeek/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfWeek/test_dayOfWeek_calendar.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfWeek/test_dayOfWeek_date_types.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfWeek/test_dayOfWeek_expressions.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfWeek/test_dayOfWeek_null_and_type_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfWeek/test_dayOfWeek_timezone_input_types.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfWeek/test_dayOfWeek_timezone_names.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfWeek/test_dayOfWeek_timezone_offsets.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfWeek/test_dayOfWeek_timezone_validation.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfYear/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfYear/test_dayOfYear_calendar.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfYear/test_dayOfYear_date_types.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfYear/test_dayOfYear_expressions.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfYear/test_dayOfYear_null_and_type_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfYear/test_dayOfYear_timezone_input_types.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfYear/test_dayOfYear_timezone_names.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfYear/test_dayOfYear_timezone_offsets.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfYear/test_dayOfYear_timezone_validation.py diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfMonth/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfMonth/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfMonth/test_dayOfMonth_calendar.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfMonth/test_dayOfMonth_calendar.py new file mode 100644 index 000000000..b0a3d8ee6 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfMonth/test_dayOfMonth_calendar.py @@ -0,0 +1,230 @@ +"""Tests for $dayOfMonth day extraction from datetimes across the calendar and year range.""" + +from datetime import datetime, timezone + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import DATE_EPOCH + +# Property [Day Extraction]: $dayOfMonth returns the day component (1-31) of a UTC date. +DAYOFMONTH_EXTRACTION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "day_1", + doc={"date": datetime(2024, 6, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dayOfMonth": "$date"}, + expected=1, + msg="$dayOfMonth should return 1 for the first day of the month", + ), + ExpressionTestCase( + "day_15", + doc={"date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dayOfMonth": "$date"}, + expected=15, + msg="$dayOfMonth should return 15 for the fifteenth day of the month", + ), + ExpressionTestCase( + "day_28", + doc={"date": datetime(2024, 6, 28, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dayOfMonth": "$date"}, + expected=28, + msg="$dayOfMonth should return 28 for the twenty-eighth day of the month", + ), + ExpressionTestCase( + "day_30", + doc={"date": datetime(2024, 6, 30, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dayOfMonth": "$date"}, + expected=30, + msg="$dayOfMonth should return 30 for the thirtieth day of the month", + ), + ExpressionTestCase( + "day_31", + doc={"date": datetime(2024, 7, 31, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dayOfMonth": "$date"}, + expected=31, + msg="$dayOfMonth should return 31 for the thirty-first day of the month", + ), +] + +# Property [Calendar Boundaries]: year edges, leap-year Feb 29, century rules, and +# sub-second precision resolve to the correct day. +DAYOFMONTH_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "first_day_of_year", + doc={"date": datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$dayOfMonth": "$date"}, + expected=1, + msg="$dayOfMonth should return 1 for the first day of the year", + ), + ExpressionTestCase( + "last_day_of_year", + doc={"date": datetime(2024, 12, 31, 23, 59, 59, tzinfo=timezone.utc)}, + expression={"$dayOfMonth": "$date"}, + expected=31, + msg="$dayOfMonth should return 31 for the last day of the year", + ), + ExpressionTestCase( + "leap_year_feb_29", + doc={"date": datetime(2024, 2, 29, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dayOfMonth": "$date"}, + expected=29, + msg="$dayOfMonth should return 29 for Feb 29 in a leap year", + ), + ExpressionTestCase( + "non_leap_year_feb_28", + doc={"date": datetime(2023, 2, 28, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dayOfMonth": "$date"}, + expected=28, + msg="$dayOfMonth should return 28 for Feb 28 in a non-leap year", + ), + ExpressionTestCase( + "leap_year_feb_29_2000", + doc={"date": datetime(2000, 2, 29, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dayOfMonth": "$date"}, + expected=29, + msg="$dayOfMonth should return 29 for Feb 29 in the century leap year 2000", + ), + ExpressionTestCase( + "non_leap_century_1900_feb_28", + doc={"date": datetime(1900, 2, 28, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dayOfMonth": "$date"}, + expected=28, + msg="$dayOfMonth should return 28 for Feb 28 in the non-leap century year 1900", + ), + ExpressionTestCase( + "millisecond_before_next_day", + doc={"date": datetime(2024, 6, 15, 23, 59, 59, 999000, tzinfo=timezone.utc)}, + expression={"$dayOfMonth": "$date"}, + expected=15, + msg="$dayOfMonth should return 15 one millisecond before the next day", + ), + ExpressionTestCase( + "millisecond_after_day_start", + doc={"date": datetime(2024, 6, 16, 0, 0, 0, 1000, tzinfo=timezone.utc)}, + expression={"$dayOfMonth": "$date"}, + expected=16, + msg="$dayOfMonth should return 16 one millisecond after the day starts", + ), + ExpressionTestCase( + "millisecond_mid_day", + doc={"date": datetime(2024, 6, 15, 12, 30, 30, 500000, tzinfo=timezone.utc)}, + expression={"$dayOfMonth": "$date"}, + expected=15, + msg="$dayOfMonth should return 15 for a mid-day instant with milliseconds", + ), + ExpressionTestCase( + "millisecond_month_boundary", + doc={"date": datetime(2024, 6, 30, 23, 59, 59, 999000, tzinfo=timezone.utc)}, + expression={"$dayOfMonth": "$date"}, + expected=30, + msg="$dayOfMonth should return 30 one millisecond before the month boundary", + ), + ExpressionTestCase( + "millisecond_leap_feb_end", + doc={"date": datetime(2024, 2, 29, 23, 59, 59, 999000, tzinfo=timezone.utc)}, + expression={"$dayOfMonth": "$date"}, + expected=29, + msg="$dayOfMonth should return 29 one millisecond before the end of leap-year February", + ), +] + +# Property [Year Range]: the day component is correct across a wide span of years. +DAYOFMONTH_YEAR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "year_2000", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dayOfMonth": "$date"}, + expected=1, + msg="$dayOfMonth should return 1 for a date in the year 2000", + ), + ExpressionTestCase( + "year_1970_epoch", + doc={"date": DATE_EPOCH}, + expression={"$dayOfMonth": "$date"}, + expected=1, + msg="$dayOfMonth should return 1 for the Unix epoch", + ), + ExpressionTestCase( + "year_1999", + doc={"date": datetime(1999, 12, 31, 23, 59, 59, tzinfo=timezone.utc)}, + expression={"$dayOfMonth": "$date"}, + expected=31, + msg="$dayOfMonth should return 31 for the last day of 1999", + ), + ExpressionTestCase( + "year_2099", + doc={"date": datetime(2099, 7, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dayOfMonth": "$date"}, + expected=15, + msg="$dayOfMonth should return 15 for a date in the year 2099", + ), + ExpressionTestCase( + "year_9999", + doc={"date": datetime(9999, 12, 31, 23, 59, 59, tzinfo=timezone.utc)}, + expression={"$dayOfMonth": "$date"}, + expected=31, + msg="$dayOfMonth should return 31 for the last representable year 9999", + ), +] + +# Property [Pre-Epoch]: negative-millisecond dates before 1970 resolve to the correct day. +DAYOFMONTH_PRE_EPOCH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "pre_epoch_1960", + doc={"date": datetime(1960, 3, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dayOfMonth": "$date"}, + expected=15, + msg="$dayOfMonth should return 15 for a pre-epoch date in 1960", + ), + ExpressionTestCase( + "pre_epoch_1900", + doc={"date": datetime(1900, 7, 4, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$dayOfMonth": "$date"}, + expected=4, + msg="$dayOfMonth should return 4 for a pre-epoch date in 1900", + ), + ExpressionTestCase( + "pre_epoch_1969_dec", + doc={"date": datetime(1969, 12, 31, 23, 59, 59, tzinfo=timezone.utc)}, + expression={"$dayOfMonth": "$date"}, + expected=31, + msg="$dayOfMonth should return 31 for the last day before the epoch", + ), + ExpressionTestCase( + "pre_epoch_1969_jan", + doc={"date": datetime(1969, 1, 1, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$dayOfMonth": "$date"}, + expected=1, + msg="$dayOfMonth should return 1 for the first day of 1969", + ), + ExpressionTestCase( + "pre_epoch_1950_leap", + doc={"date": datetime(1952, 2, 29, 6, 0, 0, tzinfo=timezone.utc)}, + expression={"$dayOfMonth": "$date"}, + expected=29, + msg="$dayOfMonth should return 29 for Feb 29 in the pre-epoch leap year 1952", + ), +] + +DAYOFMONTH_CALENDAR_TESTS: list[ExpressionTestCase] = ( + DAYOFMONTH_EXTRACTION_TESTS + + DAYOFMONTH_BOUNDARY_TESTS + + DAYOFMONTH_YEAR_TESTS + + DAYOFMONTH_PRE_EPOCH_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DAYOFMONTH_CALENDAR_TESTS)) +def test_dayOfMonth_calendar(collection, test_case: ExpressionTestCase): + """Test $dayOfMonth day extraction across the calendar and year range.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfMonth/test_dayOfMonth_date_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfMonth/test_dayOfMonth_date_types.py new file mode 100644 index 000000000..d15d875cb --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfMonth/test_dayOfMonth_date_types.py @@ -0,0 +1,187 @@ +"""Tests for $dayOfMonth with Timestamp, ObjectId, and extended-range date inputs.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.date_utils import ( + oid_from_args, + ts_from_args, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DATE_MS_BEFORE_EPOCH, + DATE_MS_EPOCH, + DATE_MS_MAX, + DATE_MS_MIN, + DATE_MS_YEAR_10000, + OID_MAX_SIGNED32, + OID_MAX_UNSIGNED32, + OID_MIN_SIGNED32, + TS_MAX_SIGNED32, + TS_MAX_UNSIGNED32, +) + +# Property [Timestamp Input]: a BSON Timestamp is accepted as a date and yields its day. +DAYOFMONTH_TIMESTAMP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "timestamp_day_1", + doc={"date": ts_from_args(2024, 6, 1, 0, 0, 0)}, + expression={"$dayOfMonth": "$date"}, + expected=1, + msg="$dayOfMonth should return 1 for a Timestamp on day 1", + ), + ExpressionTestCase( + "timestamp_day_15", + doc={"date": ts_from_args(2024, 6, 15, 0, 0, 0)}, + expression={"$dayOfMonth": "$date"}, + expected=15, + msg="$dayOfMonth should return 15 for a Timestamp on day 15", + ), + ExpressionTestCase( + "timestamp_day_31", + doc={"date": ts_from_args(2024, 7, 31, 0, 0, 0)}, + expression={"$dayOfMonth": "$date"}, + expected=31, + msg="$dayOfMonth should return 31 for a Timestamp on day 31", + ), + ExpressionTestCase( + "timestamp_zero_increment", + doc={"date": ts_from_args(2024, 6, 15, 0, 0, 0, inc=0)}, + expression={"$dayOfMonth": "$date"}, + expected=15, + msg="$dayOfMonth should return 15 for a Timestamp with a zero increment", + ), + ExpressionTestCase( + "timestamp_epoch", + doc={"date": ts_from_args(1970, 1, 1, 0, 0, 0)}, + expression={"$dayOfMonth": "$date"}, + expected=1, + msg="$dayOfMonth should return 1 for a Timestamp at the epoch", + ), +] + +# Property [ObjectId Input]: an ObjectId is accepted as a date via its embedded timestamp. +DAYOFMONTH_OBJECTID_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "objectid_day_1", + doc={"date": oid_from_args(2024, 6, 1, 0, 0, 0)}, + expression={"$dayOfMonth": "$date"}, + expected=1, + msg="$dayOfMonth should return 1 for an ObjectId on day 1", + ), + ExpressionTestCase( + "objectid_day_15", + doc={"date": oid_from_args(2024, 6, 15, 0, 0, 0)}, + expression={"$dayOfMonth": "$date"}, + expected=15, + msg="$dayOfMonth should return 15 for an ObjectId on day 15", + ), + ExpressionTestCase( + "objectid_day_31", + doc={"date": oid_from_args(2024, 7, 31, 0, 0, 0)}, + expression={"$dayOfMonth": "$date"}, + expected=31, + msg="$dayOfMonth should return 31 for an ObjectId on day 31", + ), + ExpressionTestCase( + "objectid_epoch", + doc={"date": oid_from_args(1970, 1, 1, 0, 0, 0)}, + expression={"$dayOfMonth": "$date"}, + expected=1, + msg="$dayOfMonth should return 1 for an ObjectId at the epoch", + ), +] + +# Property [Extended Range]: DatetimeMS, Timestamp, and ObjectId boundary instants +# beyond the native datetime range resolve to the correct day. +DAYOFMONTH_EXTENDED_RANGE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "date_ms_epoch", + doc={"date": DATE_MS_EPOCH}, + expression={"$dayOfMonth": "$date"}, + expected=1, + msg="$dayOfMonth should return 1 for the epoch as a DatetimeMS", + ), + ExpressionTestCase( + "date_ms_before_epoch", + doc={"date": DATE_MS_BEFORE_EPOCH}, + expression={"$dayOfMonth": "$date"}, + expected=31, + msg="$dayOfMonth should return 31 for a DatetimeMS one millisecond before the epoch", + ), + ExpressionTestCase( + "date_ms_year_10000", + doc={"date": DATE_MS_YEAR_10000}, + expression={"$dayOfMonth": "$date"}, + expected=1, + msg="$dayOfMonth should return 1 for a DatetimeMS at the year-10000 boundary", + ), + ExpressionTestCase( + "date_ms_max", + doc={"date": DATE_MS_MAX}, + expression={"$dayOfMonth": "$date"}, + expected=17, + msg="$dayOfMonth should return 17 for the maximum 64-bit DatetimeMS", + ), + ExpressionTestCase( + "date_ms_min", + doc={"date": DATE_MS_MIN}, + expression={"$dayOfMonth": "$date"}, + expected=16, + msg="$dayOfMonth should return 16 for the minimum 64-bit DatetimeMS", + ), + ExpressionTestCase( + "ts_boundary_max_s32", + doc={"date": TS_MAX_SIGNED32}, + expression={"$dayOfMonth": "$date"}, + expected=19, + msg="$dayOfMonth should return 19 for the max signed 32-bit Timestamp", + ), + ExpressionTestCase( + "ts_boundary_max_u32", + doc={"date": TS_MAX_UNSIGNED32}, + expression={"$dayOfMonth": "$date"}, + expected=7, + msg="$dayOfMonth should return 7 for the max unsigned 32-bit Timestamp", + ), + ExpressionTestCase( + "oid_boundary_max_s32", + doc={"date": OID_MAX_SIGNED32}, + expression={"$dayOfMonth": "$date"}, + expected=19, + msg="$dayOfMonth should return 19 for the max signed 32-bit ObjectId", + ), + ExpressionTestCase( + "oid_boundary_min_s32", + doc={"date": OID_MIN_SIGNED32}, + expression={"$dayOfMonth": "$date"}, + expected=13, + msg="$dayOfMonth should return 13 for the min signed 32-bit ObjectId", + ), + ExpressionTestCase( + "oid_boundary_max_u32", + doc={"date": OID_MAX_UNSIGNED32}, + expression={"$dayOfMonth": "$date"}, + expected=31, + msg="$dayOfMonth should return 31 for the max unsigned 32-bit ObjectId", + ), +] + +DAYOFMONTH_DATE_TYPES_TESTS: list[ExpressionTestCase] = ( + DAYOFMONTH_TIMESTAMP_TESTS + DAYOFMONTH_OBJECTID_TESTS + DAYOFMONTH_EXTENDED_RANGE_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DAYOFMONTH_DATE_TYPES_TESTS)) +def test_dayOfMonth_date_types(collection, test_case: ExpressionTestCase): + """Test $dayOfMonth with Timestamp, ObjectId, and extended-range date inputs.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfMonth/test_dayOfMonth_expressions.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfMonth/test_dayOfMonth_expressions.py new file mode 100644 index 000000000..a47bc2a22 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfMonth/test_dayOfMonth_expressions.py @@ -0,0 +1,160 @@ +"""Tests for $dayOfMonth argument forms, field-path resolution, and expression input.""" + +from datetime import datetime, timezone + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + ISO_DATE_INVALID_ARRAY_INPUT_ERROR, + ISO_DATE_MISSING_DATE_ERROR, + ISO_DATE_UNKNOWN_FIELD_ERROR, + TYPE_MISMATCH_DATE_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Literal Input]: an inline literal date computes the correct day of the month. +DAYOFMONTH_LITERAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "literal_date", + expression={"$dayOfMonth": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expected=15, + msg="$dayOfMonth should return the day of the month for a literal date operand", + ), +] + +# Property [Argument Forms]: the document form requires exactly a date field, and the +# operand-array form accepts only a single element. +DAYOFMONTH_ARGUMENT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "missing_date", + expression={"$dayOfMonth": {"timezone": "UTC"}}, + error_code=ISO_DATE_MISSING_DATE_ERROR, + msg="$dayOfMonth should error when the document form omits the date field", + ), + ExpressionTestCase( + "extra_field", + expression={ + "$dayOfMonth": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "extra": 1, + } + }, + error_code=ISO_DATE_UNKNOWN_FIELD_ERROR, + msg="$dayOfMonth should error for an unknown field in the document form", + ), + ExpressionTestCase( + "empty_array", + expression={"$dayOfMonth": []}, + error_code=ISO_DATE_INVALID_ARRAY_INPUT_ERROR, + msg="$dayOfMonth should error for an empty operand array", + ), + ExpressionTestCase( + "two_element_array", + expression={ + "$dayOfMonth": [ + datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + ] + }, + error_code=ISO_DATE_INVALID_ARRAY_INPUT_ERROR, + msg="$dayOfMonth should error for a two-element operand array", + ), + ExpressionTestCase( + "single_element_array", + expression={"$dayOfMonth": [datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)]}, + expected=15, + msg="$dayOfMonth should accept a single-element operand array as the date", + ), + ExpressionTestCase( + "object_expression_input", + doc={"date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dayOfMonth": {"a": "$date"}}, + error_code=ISO_DATE_UNKNOWN_FIELD_ERROR, + msg="$dayOfMonth should treat an object with an unknown key as an invalid document form", + ), +] + +# Property [Field-Path Resolution]: the operator accepts a resolved field reference; a path +# that resolves to an array (array-index or array-of-objects) feeds the type contract and errors. +DAYOFMONTH_FIELD_PATH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_field_path", + doc={"a": {"b": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}}, + expression={"$dayOfMonth": "$a.b"}, + expected=15, + msg="$dayOfMonth should accept a date resolved from a nested field path", + ), + ExpressionTestCase( + "array_index_path", + doc={"a": [{"b": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}]}, + expression={"$dayOfMonth": "$a.0.b"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfMonth should error when an array-index path resolves to an array", + ), + ExpressionTestCase( + "composite_array_path", + doc={ + "a": [ + {"b": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + {"b": datetime(2024, 7, 15, 0, 0, 0, tzinfo=timezone.utc)}, + ] + }, + expression={"$dayOfMonth": "$a.b"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfMonth should error when a path over an array of objects resolves to an array", + ), + ExpressionTestCase( + "timezone_from_field", + doc={"date": datetime(2024, 7, 1, 3, 0, 0, tzinfo=timezone.utc), "tz": "America/New_York"}, + expression={"$dayOfMonth": {"date": "$date", "timezone": "$tz"}}, + expected=30, + msg="$dayOfMonth should apply a timezone resolved from a field reference", + ), + ExpressionTestCase( + "missing_tz_field_ref", + doc={"date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dayOfMonth": {"date": "$date", "timezone": "$tz"}}, + expected=None, + msg="$dayOfMonth should return null when the timezone field reference is missing", + ), + ExpressionTestCase( + "expression_as_input", + expression={"$dayOfMonth": {"$dateFromString": {"dateString": "2024-06-15T00:00:00Z"}}}, + expected=15, + msg="$dayOfMonth should accept the result of a nested expression as the date", + ), +] + +# Property [Return Type]: $dayOfMonth returns a value of BSON type int. +DAYOFMONTH_RETURN_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "return_type", + doc={"date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$type": {"$dayOfMonth": "$date"}}, + expected="int", + msg="$dayOfMonth should return an int", + ), +] + +DAYOFMONTH_EXPRESSION_TESTS: list[ExpressionTestCase] = ( + DAYOFMONTH_LITERAL_TESTS + + DAYOFMONTH_ARGUMENT_TESTS + + DAYOFMONTH_FIELD_PATH_TESTS + + DAYOFMONTH_RETURN_TYPE_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DAYOFMONTH_EXPRESSION_TESTS)) +def test_dayOfMonth_expressions(collection, test_case: ExpressionTestCase): + """Test $dayOfMonth argument forms, field-path resolution, expression input, and return type.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfMonth/test_dayOfMonth_null_and_type_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfMonth/test_dayOfMonth_null_and_type_errors.py new file mode 100644 index 000000000..2e81f3c94 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfMonth/test_dayOfMonth_null_and_type_errors.py @@ -0,0 +1,211 @@ +"""Tests for $dayOfMonth null propagation and non-date type rejection.""" + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, Regex + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import TYPE_MISMATCH_DATE_ERROR +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + MISSING, +) + +# Property [Null Propagation]: a null or missing date resolves to null rather than an error. +DAYOFMONTH_NULL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_date", + doc={"date": None}, + expression={"$dayOfMonth": "$date"}, + expected=None, + msg="$dayOfMonth should return null for a null date", + ), + ExpressionTestCase( + "missing_date", + expression={"$dayOfMonth": MISSING}, + expected=None, + msg="$dayOfMonth should return null when the date references a missing field", + ), +] + +# Property [Type Rejection]: any non-date input type is rejected with a type-mismatch error. +DAYOFMONTH_TYPE_REJECTION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "string_date", + doc={"date": "not-a-date"}, + expression={"$dayOfMonth": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfMonth should reject a string as the date input", + ), + ExpressionTestCase( + "integer_date", + doc={"date": 42}, + expression={"$dayOfMonth": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfMonth should reject an int as the date input", + ), + ExpressionTestCase( + "int64_date", + doc={"date": Int64(42)}, + expression={"$dayOfMonth": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfMonth should reject an int64 as the date input", + ), + ExpressionTestCase( + "double_date", + doc={"date": 3.14}, + expression={"$dayOfMonth": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfMonth should reject a double as the date input", + ), + ExpressionTestCase( + "decimal128_date", + doc={"date": Decimal128("42")}, + expression={"$dayOfMonth": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfMonth should reject a decimal128 as the date input", + ), + ExpressionTestCase( + "boolean_date", + doc={"date": True}, + expression={"$dayOfMonth": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfMonth should reject a boolean as the date input", + ), + ExpressionTestCase( + "array_date", + doc={"date": [1, 2, 3]}, + expression={"$dayOfMonth": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfMonth should reject an array as the date input", + ), + ExpressionTestCase( + "object_date", + doc={"date": {"a": 1}}, + expression={"$dayOfMonth": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfMonth should reject an object as the date input", + ), + ExpressionTestCase( + "empty_string_date", + doc={"date": ""}, + expression={"$dayOfMonth": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfMonth should reject an empty string as the date input", + ), + ExpressionTestCase( + "empty_array_date", + doc={"date": []}, + expression={"$dayOfMonth": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfMonth should reject an empty array as the date input", + ), + ExpressionTestCase( + "empty_object_date", + doc={"date": {}}, + expression={"$dayOfMonth": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfMonth should reject an empty object as the date input", + ), + ExpressionTestCase( + "float_nan_date", + doc={"date": FLOAT_NAN}, + expression={"$dayOfMonth": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfMonth should reject a float NaN as the date input", + ), + ExpressionTestCase( + "decimal128_nan_date", + doc={"date": DECIMAL128_NAN}, + expression={"$dayOfMonth": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfMonth should reject a decimal128 NaN as the date input", + ), + ExpressionTestCase( + "regex_date", + doc={"date": Regex(".*")}, + expression={"$dayOfMonth": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfMonth should reject a regex as the date input", + ), + ExpressionTestCase( + "minkey_date", + doc={"date": MinKey()}, + expression={"$dayOfMonth": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfMonth should reject MinKey as the date input", + ), + ExpressionTestCase( + "maxkey_date", + doc={"date": MaxKey()}, + expression={"$dayOfMonth": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfMonth should reject MaxKey as the date input", + ), + ExpressionTestCase( + "float_inf_date", + doc={"date": FLOAT_INFINITY}, + expression={"$dayOfMonth": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfMonth should reject a float infinity as the date input", + ), + ExpressionTestCase( + "float_neg_inf_date", + doc={"date": FLOAT_NEGATIVE_INFINITY}, + expression={"$dayOfMonth": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfMonth should reject a float negative infinity as the date input", + ), + ExpressionTestCase( + "decimal128_inf_date", + doc={"date": DECIMAL128_INFINITY}, + expression={"$dayOfMonth": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfMonth should reject a decimal128 infinity as the date input", + ), + ExpressionTestCase( + "decimal128_neg_inf_date", + doc={"date": DECIMAL128_NEGATIVE_INFINITY}, + expression={"$dayOfMonth": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfMonth should reject a decimal128 negative infinity as the date input", + ), + ExpressionTestCase( + "bindata_date", + doc={"date": Binary(b"")}, + expression={"$dayOfMonth": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfMonth should reject binary data as the date input", + ), + ExpressionTestCase( + "javascript_date", + doc={"date": Code("function{}")}, + expression={"$dayOfMonth": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfMonth should reject JavaScript code as the date input", + ), +] + +DAYOFMONTH_NULL_AND_TYPE_ERROR_TESTS: list[ExpressionTestCase] = ( + DAYOFMONTH_NULL_TESTS + DAYOFMONTH_TYPE_REJECTION_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DAYOFMONTH_NULL_AND_TYPE_ERROR_TESTS)) +def test_dayOfMonth_null_and_type_errors(collection, test_case: ExpressionTestCase): + """Test $dayOfMonth null propagation and non-date type rejection.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfMonth/test_dayOfMonth_timezone_input_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfMonth/test_dayOfMonth_timezone_input_types.py new file mode 100644 index 000000000..99befe231 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfMonth/test_dayOfMonth_timezone_input_types.py @@ -0,0 +1,226 @@ +"""Tests for $dayOfMonth timezone application when the date is a Timestamp or ObjectId.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.date_utils import ( + oid_from_args, + ts_from_args, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Timestamp Input with Zones]: a Timestamp input honours the zone offset. +DAYOFMONTH_TIMESTAMP_ZONE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_olson_ts_utc_no_cross", + expression={ + "$dayOfMonth": {"date": ts_from_args(2024, 7, 15, 12, 0, 0), "timezone": "UTC"} + }, + expected=15, + msg="$dayOfMonth should return 15 for a Timestamp in UTC with no crossing", + ), + ExpressionTestCase( + "tz_olson_ts_tokyo_fwd", + expression={ + "$dayOfMonth": {"date": ts_from_args(2024, 6, 30, 22, 0, 0), "timezone": "Asia/Tokyo"} + }, + expected=1, + msg="$dayOfMonth should cross forward to day 1 for a Timestamp in Asia/Tokyo", + ), + ExpressionTestCase( + "tz_olson_ts_la_bwd", + expression={ + "$dayOfMonth": { + "date": ts_from_args(2024, 7, 1, 3, 0, 0), + "timezone": "America/Los_Angeles", + } + }, + expected=30, + msg="$dayOfMonth should cross backward to day 30 for a Timestamp in America/Los_Angeles", + ), + ExpressionTestCase( + "tz_olson_ts_kolkata_fwd", + expression={ + "$dayOfMonth": {"date": ts_from_args(2024, 3, 31, 20, 0, 0), "timezone": "Asia/Kolkata"} + }, + expected=1, + msg="$dayOfMonth should cross forward to day 1 for a Timestamp in Asia/Kolkata", + ), + ExpressionTestCase( + "tz_olson_ts_ny_year_bwd", + expression={ + "$dayOfMonth": { + "date": ts_from_args(2024, 1, 1, 3, 0, 0), + "timezone": "America/New_York", + } + }, + expected=31, + msg="$dayOfMonth should cross the year boundary backward for a Timestamp in New York", + ), + ExpressionTestCase( + "tz_olson_ts_helsinki_year_fwd", + expression={ + "$dayOfMonth": { + "date": ts_from_args(2024, 12, 31, 23, 0, 0), + "timezone": "Europe/Helsinki", + } + }, + expected=1, + msg="$dayOfMonth should cross the year boundary forward for a Timestamp in Europe/Helsinki", + ), + ExpressionTestCase( + "tz_offset_ts_plus9_fwd", + expression={ + "$dayOfMonth": {"date": ts_from_args(2024, 6, 30, 22, 0, 0), "timezone": "+09:00"} + }, + expected=1, + msg="$dayOfMonth should cross forward to day 1 for a Timestamp at a +09:00 offset", + ), + ExpressionTestCase( + "tz_offset_ts_minus8_bwd", + expression={ + "$dayOfMonth": {"date": ts_from_args(2024, 7, 1, 3, 0, 0), "timezone": "-08:00"} + }, + expected=30, + msg="$dayOfMonth should cross backward to day 30 for a Timestamp at a -08:00 offset", + ), + ExpressionTestCase( + "tz_offset_ts_plus530_fwd", + expression={ + "$dayOfMonth": {"date": ts_from_args(2024, 3, 31, 20, 0, 0), "timezone": "+05:30"} + }, + expected=1, + msg="$dayOfMonth should cross forward to day 1 for a Timestamp at a +05:30 offset", + ), + ExpressionTestCase( + "tz_offset_ts_plus545_fwd", + expression={ + "$dayOfMonth": {"date": ts_from_args(2024, 8, 31, 23, 0, 0), "timezone": "+05:45"} + }, + expected=1, + msg="$dayOfMonth should cross forward to day 1 for a Timestamp at a +05:45 offset", + ), + ExpressionTestCase( + "tz_offset_ts_plus3_leap_fwd", + expression={ + "$dayOfMonth": {"date": ts_from_args(2024, 2, 29, 23, 30, 0), "timezone": "+03:00"} + }, + expected=1, + msg="$dayOfMonth should cross the leap-day boundary forward for a Timestamp at +03:00", + ), +] + +# Property [ObjectId Input with Zones]: an ObjectId input honours the zone offset. +DAYOFMONTH_OBJECTID_ZONE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_olson_oid_utc_no_cross", + expression={ + "$dayOfMonth": {"date": oid_from_args(2024, 7, 15, 12, 0, 0), "timezone": "UTC"} + }, + expected=15, + msg="$dayOfMonth should return 15 for an ObjectId in UTC with no crossing", + ), + ExpressionTestCase( + "tz_olson_oid_tokyo_fwd", + expression={ + "$dayOfMonth": {"date": oid_from_args(2024, 6, 30, 22, 0, 0), "timezone": "Asia/Tokyo"} + }, + expected=1, + msg="$dayOfMonth should cross forward to day 1 for an ObjectId in Asia/Tokyo", + ), + ExpressionTestCase( + "tz_olson_oid_la_bwd", + expression={ + "$dayOfMonth": { + "date": oid_from_args(2024, 7, 1, 3, 0, 0), + "timezone": "America/Los_Angeles", + } + }, + expected=30, + msg="$dayOfMonth should cross backward to day 30 for an ObjectId in America/Los_Angeles", + ), + ExpressionTestCase( + "tz_olson_oid_kolkata_fwd", + expression={ + "$dayOfMonth": { + "date": oid_from_args(2024, 3, 31, 20, 0, 0), + "timezone": "Asia/Kolkata", + } + }, + expected=1, + msg="$dayOfMonth should cross forward to day 1 for an ObjectId in Asia/Kolkata", + ), + ExpressionTestCase( + "tz_olson_oid_ny_year_bwd", + expression={ + "$dayOfMonth": { + "date": oid_from_args(2024, 1, 1, 3, 0, 0), + "timezone": "America/New_York", + } + }, + expected=31, + msg="$dayOfMonth should cross the year boundary backward for an ObjectId in New York", + ), + ExpressionTestCase( + "tz_olson_oid_helsinki_year_fwd", + expression={ + "$dayOfMonth": { + "date": oid_from_args(2024, 12, 31, 23, 0, 0), + "timezone": "Europe/Helsinki", + } + }, + expected=1, + msg="$dayOfMonth should cross the year boundary forward for an ObjectId in Europe/Helsinki", + ), + ExpressionTestCase( + "tz_offset_oid_plus9_fwd", + expression={ + "$dayOfMonth": {"date": oid_from_args(2024, 6, 30, 22, 0, 0), "timezone": "+09:00"} + }, + expected=1, + msg="$dayOfMonth should cross forward to day 1 for an ObjectId at a +09:00 offset", + ), + ExpressionTestCase( + "tz_offset_oid_minus8_bwd", + expression={ + "$dayOfMonth": {"date": oid_from_args(2024, 7, 1, 3, 0, 0), "timezone": "-08:00"} + }, + expected=30, + msg="$dayOfMonth should cross backward to day 30 for an ObjectId at a -08:00 offset", + ), + ExpressionTestCase( + "tz_offset_oid_plus530_fwd", + expression={ + "$dayOfMonth": {"date": oid_from_args(2024, 3, 31, 20, 0, 0), "timezone": "+05:30"} + }, + expected=1, + msg="$dayOfMonth should cross forward to day 1 for an ObjectId at a +05:30 offset", + ), + ExpressionTestCase( + "tz_offset_oid_plus3_leap_fwd", + expression={ + "$dayOfMonth": {"date": oid_from_args(2024, 2, 29, 23, 30, 0), "timezone": "+03:00"} + }, + expected=1, + msg="$dayOfMonth should cross the leap-day boundary forward for an ObjectId at +03:00", + ), +] + +DAYOFMONTH_TIMEZONE_INPUT_TYPES_TESTS: list[ExpressionTestCase] = ( + DAYOFMONTH_TIMESTAMP_ZONE_TESTS + DAYOFMONTH_OBJECTID_ZONE_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DAYOFMONTH_TIMEZONE_INPUT_TYPES_TESTS)) +def test_dayOfMonth_timezone_input_types(collection, test_case: ExpressionTestCase): + """Test $dayOfMonth timezone application for Timestamp and ObjectId date inputs.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfMonth/test_dayOfMonth_timezone_names.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfMonth/test_dayOfMonth_timezone_names.py new file mode 100644 index 000000000..e66c8529f --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfMonth/test_dayOfMonth_timezone_names.py @@ -0,0 +1,248 @@ +"""Tests for $dayOfMonth named-timezone application, including DST and abbreviations.""" + +from datetime import datetime, timezone + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Named Zones]: a named zone or abbreviation shifts the instant before the day is +# taken, which may cross a day, month, year, or leap boundary depending on the offset and DST. +DAYOFMONTH_OLSON_DATETIME_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_olson_dt_utc_no_cross", + expression={ + "$dayOfMonth": { + "date": datetime(2024, 7, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "UTC", + } + }, + expected=15, + msg="$dayOfMonth should return 15 for UTC with no day crossing", + ), + ExpressionTestCase( + "tz_olson_dt_ny_no_cross", + expression={ + "$dayOfMonth": { + "date": datetime(2024, 7, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "America/New_York", + } + }, + expected=15, + msg="$dayOfMonth should return 15 for America/New_York with no day crossing", + ), + ExpressionTestCase( + "tz_olson_dt_tokyo_no_cross", + expression={ + "$dayOfMonth": { + "date": datetime(2024, 7, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "Asia/Tokyo", + } + }, + expected=15, + msg="$dayOfMonth should return 15 for Asia/Tokyo with no day crossing", + ), + ExpressionTestCase( + "tz_olson_dt_tokyo_fwd", + expression={ + "$dayOfMonth": { + "date": datetime(2024, 6, 30, 22, 0, 0, tzinfo=timezone.utc), + "timezone": "Asia/Tokyo", + } + }, + expected=1, + msg="$dayOfMonth should cross forward to day 1 for Asia/Tokyo (+09:00)", + ), + ExpressionTestCase( + "tz_olson_dt_la_bwd", + expression={ + "$dayOfMonth": { + "date": datetime(2024, 7, 1, 3, 0, 0, tzinfo=timezone.utc), + "timezone": "America/Los_Angeles", + } + }, + expected=30, + msg="$dayOfMonth should cross backward to day 30 for America/Los_Angeles (PDT)", + ), + ExpressionTestCase( + "tz_olson_dt_denver_bwd", + expression={ + "$dayOfMonth": { + "date": datetime(2024, 11, 1, 5, 0, 0, tzinfo=timezone.utc), + "timezone": "America/Denver", + } + }, + expected=31, + msg="$dayOfMonth should cross backward to day 31 for America/Denver (MDT)", + ), + ExpressionTestCase( + "tz_olson_dt_kolkata_fwd", + expression={ + "$dayOfMonth": { + "date": datetime(2024, 3, 31, 20, 0, 0, tzinfo=timezone.utc), + "timezone": "Asia/Kolkata", + } + }, + expected=1, + msg="$dayOfMonth should cross forward to day 1 for Asia/Kolkata (+05:30)", + ), + ExpressionTestCase( + "tz_olson_dt_kathmandu_fwd", + expression={ + "$dayOfMonth": { + "date": datetime(2024, 8, 31, 23, 0, 0, tzinfo=timezone.utc), + "timezone": "Asia/Kathmandu", + } + }, + expected=1, + msg="$dayOfMonth should cross forward to day 1 for Asia/Kathmandu (+05:45)", + ), + ExpressionTestCase( + "tz_olson_dt_ny_year_bwd", + expression={ + "$dayOfMonth": { + "date": datetime(2024, 1, 1, 3, 0, 0, tzinfo=timezone.utc), + "timezone": "America/New_York", + } + }, + expected=31, + msg="$dayOfMonth should cross the year boundary backward to day 31 for America/New_York", + ), + ExpressionTestCase( + "tz_olson_dt_ny_year_stays", + expression={ + "$dayOfMonth": { + "date": datetime(2024, 1, 1, 6, 0, 0, tzinfo=timezone.utc), + "timezone": "America/New_York", + } + }, + expected=1, + msg="$dayOfMonth should stay on day 1 for America/New_York after the year boundary", + ), + ExpressionTestCase( + "tz_olson_dt_helsinki_year_fwd", + expression={ + "$dayOfMonth": { + "date": datetime(2024, 12, 31, 23, 0, 0, tzinfo=timezone.utc), + "timezone": "Europe/Helsinki", + } + }, + expected=1, + msg="$dayOfMonth should cross the year boundary forward to day 1 for Europe/Helsinki", + ), + ExpressionTestCase( + "tz_olson_dt_moscow_leap_fwd", + expression={ + "$dayOfMonth": { + "date": datetime(2024, 2, 29, 23, 30, 0, tzinfo=timezone.utc), + "timezone": "Europe/Moscow", + } + }, + expected=1, + msg="$dayOfMonth should cross the leap-day boundary forward to day 1 for Europe/Moscow", + ), + ExpressionTestCase( + "tz_olson_dt_ny_leap_bwd", + expression={ + "$dayOfMonth": { + "date": datetime(2024, 3, 1, 2, 0, 0, tzinfo=timezone.utc), + "timezone": "America/New_York", + } + }, + expected=29, + msg="$dayOfMonth should cross backward to leap day 29 for America/New_York", + ), + ExpressionTestCase( + "tz_olson_dt_ny_dst_spring", + expression={ + "$dayOfMonth": { + "date": datetime(2024, 3, 10, 7, 0, 0, tzinfo=timezone.utc), + "timezone": "America/New_York", + } + }, + expected=10, + msg="$dayOfMonth should return 10 across the spring DST transition in America/New_York", + ), + ExpressionTestCase( + "tz_olson_dt_ny_dst_fall", + expression={ + "$dayOfMonth": { + "date": datetime(2024, 11, 3, 6, 0, 0, tzinfo=timezone.utc), + "timezone": "America/New_York", + } + }, + expected=3, + msg="$dayOfMonth should return 3 across the fall DST transition in America/New_York", + ), + ExpressionTestCase( + "tz_olson_dt_london_bst_start", + expression={ + "$dayOfMonth": { + "date": datetime(2024, 3, 31, 0, 30, 0, tzinfo=timezone.utc), + "timezone": "Europe/London", + } + }, + expected=31, + msg="$dayOfMonth should return 31 at the Europe/London BST start", + ), + ExpressionTestCase( + "tz_olson_dt_london_winter", + expression={ + "$dayOfMonth": { + "date": datetime(2020, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "Europe/London", + } + }, + expected=1, + msg="$dayOfMonth should return 1 for Europe/London in winter (GMT)", + ), + ExpressionTestCase( + "tz_olson_dt_london_bst", + expression={ + "$dayOfMonth": { + "date": datetime(2020, 7, 1, 23, 30, 0, tzinfo=timezone.utc), + "timezone": "Europe/London", + } + }, + expected=2, + msg="$dayOfMonth should cross forward to day 2 for Europe/London in summer (BST)", + ), + ExpressionTestCase( + "tz_olson_dt_pacific_apia", + expression={ + "$dayOfMonth": { + "date": datetime(2020, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "Pacific/Apia", + } + }, + expected=2, + msg="$dayOfMonth should cross forward to day 2 for Pacific/Apia (+13:00)", + ), + ExpressionTestCase( + "tz_est_abbreviation", + expression={ + "$dayOfMonth": { + "date": datetime(2024, 7, 1, 3, 0, 0, tzinfo=timezone.utc), + "timezone": "EST", + } + }, + expected=30, + msg="$dayOfMonth should accept the EST three-letter abbreviation", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(DAYOFMONTH_OLSON_DATETIME_TESTS)) +def test_dayOfMonth_timezone_names(collection, test_case: ExpressionTestCase): + """Test $dayOfMonth named-timezone application across zones, DST, and abbreviations.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfMonth/test_dayOfMonth_timezone_offsets.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfMonth/test_dayOfMonth_timezone_offsets.py new file mode 100644 index 000000000..cca6875fd --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfMonth/test_dayOfMonth_timezone_offsets.py @@ -0,0 +1,259 @@ +"""Tests for $dayOfMonth UTC-offset timezone application, including edge and unusual offsets.""" + +from datetime import datetime, timezone + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [UTC Offsets, datetime]: an explicit +HH:MM/-HH:MM offset shifts the instant, +# including half/quarter-hour, extreme, and out-of-range offsets the server still accepts. +DAYOFMONTH_OFFSET_DATETIME_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_offset_dt_plus0_no_cross", + expression={ + "$dayOfMonth": { + "date": datetime(2024, 7, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "+00:00", + } + }, + expected=15, + msg="$dayOfMonth should return 15 for a +00:00 offset with no crossing", + ), + ExpressionTestCase( + "tz_offset_dt_minus5_no_cross", + expression={ + "$dayOfMonth": { + "date": datetime(2024, 7, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "-05:00", + } + }, + expected=15, + msg="$dayOfMonth should return 15 for a -05:00 offset with no crossing", + ), + ExpressionTestCase( + "tz_offset_dt_plus9_fwd", + expression={ + "$dayOfMonth": { + "date": datetime(2024, 6, 30, 22, 0, 0, tzinfo=timezone.utc), + "timezone": "+09:00", + } + }, + expected=1, + msg="$dayOfMonth should cross forward to day 1 for a +09:00 offset", + ), + ExpressionTestCase( + "tz_offset_dt_minus8_bwd", + expression={ + "$dayOfMonth": { + "date": datetime(2024, 7, 1, 3, 0, 0, tzinfo=timezone.utc), + "timezone": "-08:00", + } + }, + expected=30, + msg="$dayOfMonth should cross backward to day 30 for a -08:00 offset", + ), + ExpressionTestCase( + "tz_offset_dt_plus530_fwd", + expression={ + "$dayOfMonth": { + "date": datetime(2024, 3, 31, 20, 0, 0, tzinfo=timezone.utc), + "timezone": "+05:30", + } + }, + expected=1, + msg="$dayOfMonth should cross forward to day 1 for a +05:30 half-hour offset", + ), + ExpressionTestCase( + "tz_offset_dt_plus545_fwd", + expression={ + "$dayOfMonth": { + "date": datetime(2024, 8, 31, 23, 0, 0, tzinfo=timezone.utc), + "timezone": "+05:45", + } + }, + expected=1, + msg="$dayOfMonth should cross forward to day 1 for a +05:45 quarter-hour offset", + ), + ExpressionTestCase( + "tz_offset_dt_minus5_year_bwd", + expression={ + "$dayOfMonth": { + "date": datetime(2024, 1, 1, 3, 0, 0, tzinfo=timezone.utc), + "timezone": "-05:00", + } + }, + expected=31, + msg="$dayOfMonth should cross the year boundary backward to day 31 for a -05:00 offset", + ), + ExpressionTestCase( + "tz_offset_dt_plus2_year_fwd", + expression={ + "$dayOfMonth": { + "date": datetime(2024, 12, 31, 23, 0, 0, tzinfo=timezone.utc), + "timezone": "+02:00", + } + }, + expected=1, + msg="$dayOfMonth should cross the year boundary forward to day 1 for a +02:00 offset", + ), + ExpressionTestCase( + "tz_offset_dt_plus3_leap_fwd", + expression={ + "$dayOfMonth": { + "date": datetime(2024, 2, 29, 23, 30, 0, tzinfo=timezone.utc), + "timezone": "+03:00", + } + }, + expected=1, + msg="$dayOfMonth should cross the leap-day boundary forward to day 1 for a +03:00 offset", + ), + ExpressionTestCase( + "tz_offset_dt_minus5_leap_bwd", + expression={ + "$dayOfMonth": { + "date": datetime(2024, 3, 1, 2, 0, 0, tzinfo=timezone.utc), + "timezone": "-05:00", + } + }, + expected=29, + msg="$dayOfMonth should cross backward to leap day 29 for a -05:00 offset", + ), + ExpressionTestCase( + "tz_offset_dt_plus14_fwd", + expression={ + "$dayOfMonth": { + "date": datetime(2024, 6, 30, 11, 0, 0, tzinfo=timezone.utc), + "timezone": "+14:00", + } + }, + expected=1, + msg="$dayOfMonth should cross forward to day 1 for the extreme +14:00 offset", + ), + ExpressionTestCase( + "tz_offset_dt_minus12_bwd", + expression={ + "$dayOfMonth": { + "date": datetime(2024, 7, 1, 11, 0, 0, tzinfo=timezone.utc), + "timezone": "-12:00", + } + }, + expected=30, + msg="$dayOfMonth should cross backward to day 30 for the extreme -12:00 offset", + ), + ExpressionTestCase( + "tz_offset_no_colon", + expression={ + "$dayOfMonth": { + "date": datetime(2024, 7, 1, 3, 0, 0, tzinfo=timezone.utc), + "timezone": "-0500", + } + }, + expected=30, + msg="$dayOfMonth should accept a compact -0500 offset without a colon", + ), + ExpressionTestCase( + "tz_offset_hour_only", + expression={ + "$dayOfMonth": { + "date": datetime(2024, 7, 1, 3, 0, 0, tzinfo=timezone.utc), + "timezone": "-08", + } + }, + expected=30, + msg="$dayOfMonth should accept an hour-only -08 offset", + ), + ExpressionTestCase( + "tz_offset_minus13", + expression={ + "$dayOfMonth": { + "date": datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "-13:00", + } + }, + expected=31, + msg="$dayOfMonth should accept a -13:00 offset", + ), + ExpressionTestCase( + "tz_offset_plus15", + expression={ + "$dayOfMonth": { + "date": datetime(2024, 6, 30, 10, 0, 0, tzinfo=timezone.utc), + "timezone": "+15:00", + } + }, + expected=1, + msg="$dayOfMonth should accept a +15:00 offset", + ), + ExpressionTestCase( + "tz_offset_minus0330", + expression={ + "$dayOfMonth": { + "date": datetime(2024, 7, 1, 2, 0, 0, tzinfo=timezone.utc), + "timezone": "-03:30", + } + }, + expected=30, + msg="$dayOfMonth should accept a -03:30 half-hour west offset", + ), + ExpressionTestCase( + "tz_offset_plus0570", + expression={ + "$dayOfMonth": { + "date": datetime(2024, 6, 30, 17, 0, 0, tzinfo=timezone.utc), + "timezone": "+05:70", + } + }, + expected=30, + msg="$dayOfMonth should accept a +05:70 (70-minute) offset", + ), + ExpressionTestCase( + "tz_offset_minus0570", + expression={ + "$dayOfMonth": { + "date": datetime(2024, 7, 1, 5, 0, 0, tzinfo=timezone.utc), + "timezone": "-05:70", + } + }, + expected=30, + msg="$dayOfMonth should accept a -05:70 (70-minute) offset", + ), + ExpressionTestCase( + "tz_offset_plus2500", + expression={ + "$dayOfMonth": { + "date": datetime(2024, 6, 29, 0, 0, 0, tzinfo=timezone.utc), + "timezone": "+25:00", + } + }, + expected=30, + msg="$dayOfMonth should accept a +25:00 (25-hour) offset", + ), + ExpressionTestCase( + "tz_offset_minus2500", + expression={ + "$dayOfMonth": { + "date": datetime(2024, 7, 2, 0, 0, 0, tzinfo=timezone.utc), + "timezone": "-25:00", + } + }, + expected=30, + msg="$dayOfMonth should accept a -25:00 (25-hour) offset", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(DAYOFMONTH_OFFSET_DATETIME_TESTS)) +def test_dayOfMonth_timezone_offsets(collection, test_case: ExpressionTestCase): + """Test $dayOfMonth UTC-offset timezone application, including edge and unusual offsets.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfMonth/test_dayOfMonth_timezone_validation.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfMonth/test_dayOfMonth_timezone_validation.py new file mode 100644 index 000000000..23016aa55 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfMonth/test_dayOfMonth_timezone_validation.py @@ -0,0 +1,151 @@ +"""Tests for $dayOfMonth timezone validation and null-timezone propagation.""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + INVALID_TIMEZONE_ERROR, + INVALID_TIMEZONE_TYPE_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Timezone Validation]: unparseable zone strings, wrong-typed timezones, and +# null timezones are rejected or propagate null. +DAYOFMONTH_TIMEZONE_VALIDATION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "invalid_tz_olson", + expression={ + "$dayOfMonth": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "Not/A_Timezone", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$dayOfMonth should reject an unparseable Olson-like timezone string", + ), + ExpressionTestCase( + "invalid_tz_nonexistent_olson", + expression={ + "$dayOfMonth": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "America/Nowhere", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$dayOfMonth should reject a non-existent Olson timezone", + ), + ExpressionTestCase( + "invalid_tz_offset_format", + expression={ + "$dayOfMonth": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "25:00", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$dayOfMonth should reject a bare 25:00 offset without a sign", + ), + ExpressionTestCase( + "invalid_tz_numeric", + expression={ + "$dayOfMonth": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "123", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$dayOfMonth should reject a numeric-string timezone", + ), + ExpressionTestCase( + "invalid_tz_empty_string", + expression={ + "$dayOfMonth": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$dayOfMonth should reject an empty-string timezone", + ), + ExpressionTestCase( + "invalid_tz_olson_lowercase", + expression={ + "$dayOfMonth": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "america/new_york", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$dayOfMonth should reject an all-lowercase Olson name", + ), + ExpressionTestCase( + "invalid_tz_olson_uppercase", + expression={ + "$dayOfMonth": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "AMERICA/NEW_YORK", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$dayOfMonth should reject an all-uppercase Olson name", + ), + *[ + ExpressionTestCase( + f"invalid_tz_{tid}", + expression={ + "$dayOfMonth": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": val, + } + }, + error_code=INVALID_TIMEZONE_TYPE_ERROR, + msg=f"$dayOfMonth should reject a {tid} timezone", + ) + for tid, val in [ + ("int", 5), + ("int64", Int64(5)), + ("double", 3.14), + ("decimal128", Decimal128("5")), + ("bool", True), + ("object", {"tz": "UTC"}), + ("array", ["UTC"]), + ("datetime", datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] + ], + ExpressionTestCase( + "null_tz", + expression={ + "$dayOfMonth": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": None, + } + }, + expected=None, + msg="$dayOfMonth should return null for a null timezone", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(DAYOFMONTH_TIMEZONE_VALIDATION_TESTS)) +def test_dayOfMonth_timezone_validation(collection, test_case: ExpressionTestCase): + """Test $dayOfMonth timezone validation and null-timezone propagation.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfWeek/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfWeek/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfWeek/test_dayOfWeek_calendar.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfWeek/test_dayOfWeek_calendar.py new file mode 100644 index 000000000..a3df0852f --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfWeek/test_dayOfWeek_calendar.py @@ -0,0 +1,238 @@ +"""Tests for $dayOfWeek day-of-week extraction across the calendar and year range.""" + +from datetime import datetime, timezone + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import DATE_EPOCH + +# Property [Day Extraction]: $dayOfWeek returns the day of the week (1=Sunday to 7=Saturday) +# of a UTC date. +DAYOFWEEK_EXTRACTION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "sunday", + doc={"date": datetime(2024, 6, 30, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dayOfWeek": "$date"}, + expected=1, + msg="$dayOfWeek should return 1 for a Sunday", + ), + ExpressionTestCase( + "monday", + doc={"date": datetime(2024, 7, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dayOfWeek": "$date"}, + expected=2, + msg="$dayOfWeek should return 2 for a Monday", + ), + ExpressionTestCase( + "tuesday", + doc={"date": datetime(2024, 7, 2, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dayOfWeek": "$date"}, + expected=3, + msg="$dayOfWeek should return 3 for a Tuesday", + ), + ExpressionTestCase( + "wednesday", + doc={"date": datetime(2024, 7, 3, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dayOfWeek": "$date"}, + expected=4, + msg="$dayOfWeek should return 4 for a Wednesday", + ), + ExpressionTestCase( + "thursday", + doc={"date": datetime(2024, 7, 4, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dayOfWeek": "$date"}, + expected=5, + msg="$dayOfWeek should return 5 for a Thursday", + ), + ExpressionTestCase( + "friday", + doc={"date": datetime(2024, 7, 5, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dayOfWeek": "$date"}, + expected=6, + msg="$dayOfWeek should return 6 for a Friday", + ), + ExpressionTestCase( + "saturday", + doc={"date": datetime(2024, 7, 6, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dayOfWeek": "$date"}, + expected=7, + msg="$dayOfWeek should return 7 for a Saturday", + ), +] + +# Property [Calendar Boundaries]: year edges, leap-year Feb 29, century rules, and +# sub-second precision resolve to the correct day of the week. +DAYOFWEEK_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "first_day_of_year", + doc={"date": datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$dayOfWeek": "$date"}, + expected=2, + msg="$dayOfWeek should return 2 for the first day of the year", + ), + ExpressionTestCase( + "last_day_of_year", + doc={"date": datetime(2024, 12, 31, 23, 59, 59, tzinfo=timezone.utc)}, + expression={"$dayOfWeek": "$date"}, + expected=3, + msg="$dayOfWeek should return 3 for the last day of the year", + ), + ExpressionTestCase( + "leap_year_feb_29", + doc={"date": datetime(2024, 2, 29, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dayOfWeek": "$date"}, + expected=5, + msg="$dayOfWeek should return 5 for Feb 29 in a leap year", + ), + ExpressionTestCase( + "non_leap_year_feb_28", + doc={"date": datetime(2023, 2, 28, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dayOfWeek": "$date"}, + expected=3, + msg="$dayOfWeek should return 3 for Feb 28 in a non-leap year", + ), + ExpressionTestCase( + "leap_year_feb_29_2000", + doc={"date": datetime(2000, 2, 29, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dayOfWeek": "$date"}, + expected=3, + msg="$dayOfWeek should return 3 for Feb 29 in the century leap year 2000", + ), + ExpressionTestCase( + "non_leap_century_1900_feb_28", + doc={"date": datetime(1900, 2, 28, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dayOfWeek": "$date"}, + expected=4, + msg="$dayOfWeek should return 4 for Feb 28 in the non-leap century year 1900", + ), + ExpressionTestCase( + "millisecond_after_day_start", + doc={"date": datetime(2024, 7, 1, 0, 0, 0, 1000, tzinfo=timezone.utc)}, + expression={"$dayOfWeek": "$date"}, + expected=2, + msg="$dayOfWeek should return 2 one millisecond after the day starts", + ), + ExpressionTestCase( + "millisecond_mid_day", + doc={"date": datetime(2024, 6, 15, 12, 30, 30, 500000, tzinfo=timezone.utc)}, + expression={"$dayOfWeek": "$date"}, + expected=7, + msg="$dayOfWeek should return 7 for a mid-day instant with milliseconds", + ), + ExpressionTestCase( + "millisecond_month_boundary", + doc={"date": datetime(2024, 6, 30, 23, 59, 59, 999000, tzinfo=timezone.utc)}, + expression={"$dayOfWeek": "$date"}, + expected=1, + msg="$dayOfWeek should return 1 one millisecond before the month boundary", + ), + ExpressionTestCase( + "millisecond_year_boundary", + doc={"date": datetime(2024, 12, 31, 23, 59, 59, 999000, tzinfo=timezone.utc)}, + expression={"$dayOfWeek": "$date"}, + expected=3, + msg="$dayOfWeek should return 3 one millisecond before the year boundary", + ), +] + +# Property [Year Range]: the day of the week is correct across a wide span of years. +DAYOFWEEK_YEAR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "year_2000", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dayOfWeek": "$date"}, + expected=7, + msg="$dayOfWeek should return 7 for a date in the year 2000", + ), + ExpressionTestCase( + "year_1970_epoch", + doc={"date": DATE_EPOCH}, + expression={"$dayOfWeek": "$date"}, + expected=5, + msg="$dayOfWeek should return 5 for the Unix epoch", + ), + ExpressionTestCase( + "year_1999", + doc={"date": datetime(1999, 12, 31, 23, 59, 59, tzinfo=timezone.utc)}, + expression={"$dayOfWeek": "$date"}, + expected=6, + msg="$dayOfWeek should return 6 for the last day of 1999", + ), + ExpressionTestCase( + "year_2099", + doc={"date": datetime(2099, 7, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dayOfWeek": "$date"}, + expected=4, + msg="$dayOfWeek should return 4 for a date in the year 2099", + ), + ExpressionTestCase( + "year_9999", + doc={"date": datetime(9999, 12, 31, 23, 59, 59, tzinfo=timezone.utc)}, + expression={"$dayOfWeek": "$date"}, + expected=6, + msg="$dayOfWeek should return 6 for the last representable year 9999", + ), +] + +# Property [Pre-Epoch]: negative-millisecond dates before 1970 resolve to the correct day. +DAYOFWEEK_PRE_EPOCH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "pre_epoch_1960", + doc={"date": datetime(1960, 3, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dayOfWeek": "$date"}, + expected=3, + msg="$dayOfWeek should return 3 for a pre-epoch date in 1960", + ), + ExpressionTestCase( + "pre_epoch_1900", + doc={"date": datetime(1900, 7, 4, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$dayOfWeek": "$date"}, + expected=4, + msg="$dayOfWeek should return 4 for a pre-epoch date in 1900", + ), + ExpressionTestCase( + "pre_epoch_1969_dec", + doc={"date": datetime(1969, 12, 31, 23, 59, 59, tzinfo=timezone.utc)}, + expression={"$dayOfWeek": "$date"}, + expected=4, + msg="$dayOfWeek should return 4 for the last day before the epoch", + ), + ExpressionTestCase( + "pre_epoch_1969_jan", + doc={"date": datetime(1969, 1, 1, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$dayOfWeek": "$date"}, + expected=4, + msg="$dayOfWeek should return 4 for the first day of 1969", + ), + ExpressionTestCase( + "pre_epoch_1950_leap", + doc={"date": datetime(1952, 2, 29, 6, 0, 0, tzinfo=timezone.utc)}, + expression={"$dayOfWeek": "$date"}, + expected=6, + msg="$dayOfWeek should return 6 for Feb 29 in the pre-epoch leap year 1952", + ), +] + +DAYOFWEEK_CALENDAR_TESTS: list[ExpressionTestCase] = ( + DAYOFWEEK_EXTRACTION_TESTS + + DAYOFWEEK_BOUNDARY_TESTS + + DAYOFWEEK_YEAR_TESTS + + DAYOFWEEK_PRE_EPOCH_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DAYOFWEEK_CALENDAR_TESTS)) +def test_dayOfWeek_calendar(collection, test_case: ExpressionTestCase): + """Test $dayOfWeek day-of-week extraction across the calendar and year range.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfWeek/test_dayOfWeek_date_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfWeek/test_dayOfWeek_date_types.py new file mode 100644 index 000000000..45e67f16f --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfWeek/test_dayOfWeek_date_types.py @@ -0,0 +1,187 @@ +"""Tests for $dayOfWeek with Timestamp, ObjectId, and extended-range date inputs.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.date_utils import ( + oid_from_args, + ts_from_args, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DATE_MS_BEFORE_EPOCH, + DATE_MS_EPOCH, + DATE_MS_MAX, + DATE_MS_MIN, + DATE_MS_YEAR_10000, + OID_MAX_SIGNED32, + OID_MAX_UNSIGNED32, + OID_MIN_SIGNED32, + TS_MAX_SIGNED32, + TS_MAX_UNSIGNED32, +) + +# Property [Timestamp Input]: a BSON Timestamp is accepted as a date and yields its day of week. +DAYOFWEEK_TIMESTAMP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "timestamp_sunday", + doc={"date": ts_from_args(2024, 6, 30, 12, 0, 0)}, + expression={"$dayOfWeek": "$date"}, + expected=1, + msg="$dayOfWeek should return 1 for a Timestamp on a Sunday", + ), + ExpressionTestCase( + "timestamp_monday", + doc={"date": ts_from_args(2024, 7, 1, 12, 0, 0)}, + expression={"$dayOfWeek": "$date"}, + expected=2, + msg="$dayOfWeek should return 2 for a Timestamp on a Monday", + ), + ExpressionTestCase( + "timestamp_saturday", + doc={"date": ts_from_args(2024, 7, 6, 12, 0, 0)}, + expression={"$dayOfWeek": "$date"}, + expected=7, + msg="$dayOfWeek should return 7 for a Timestamp on a Saturday", + ), + ExpressionTestCase( + "timestamp_zero_increment", + doc={"date": ts_from_args(2024, 6, 30, 12, 0, 0, inc=0)}, + expression={"$dayOfWeek": "$date"}, + expected=1, + msg="$dayOfWeek should return 1 for a Timestamp with a zero increment", + ), + ExpressionTestCase( + "timestamp_epoch", + doc={"date": ts_from_args(1970, 1, 1, 0, 0, 0)}, + expression={"$dayOfWeek": "$date"}, + expected=5, + msg="$dayOfWeek should return 5 for a Timestamp at the epoch", + ), +] + +# Property [ObjectId Input]: an ObjectId is accepted as a date via its embedded timestamp. +DAYOFWEEK_OBJECTID_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "objectid_sunday", + doc={"date": oid_from_args(2024, 6, 30, 12, 0, 0)}, + expression={"$dayOfWeek": "$date"}, + expected=1, + msg="$dayOfWeek should return 1 for an ObjectId on a Sunday", + ), + ExpressionTestCase( + "objectid_monday", + doc={"date": oid_from_args(2024, 7, 1, 12, 0, 0)}, + expression={"$dayOfWeek": "$date"}, + expected=2, + msg="$dayOfWeek should return 2 for an ObjectId on a Monday", + ), + ExpressionTestCase( + "objectid_saturday", + doc={"date": oid_from_args(2024, 7, 6, 12, 0, 0)}, + expression={"$dayOfWeek": "$date"}, + expected=7, + msg="$dayOfWeek should return 7 for an ObjectId on a Saturday", + ), + ExpressionTestCase( + "objectid_epoch", + doc={"date": oid_from_args(1970, 1, 1, 0, 0, 0)}, + expression={"$dayOfWeek": "$date"}, + expected=5, + msg="$dayOfWeek should return 5 for an ObjectId at the epoch", + ), +] + +# Property [Extended Range]: DatetimeMS, Timestamp, and ObjectId boundary instants +# beyond the native datetime range resolve to the correct day of the week. +DAYOFWEEK_EXTENDED_RANGE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "date_ms_epoch", + doc={"date": DATE_MS_EPOCH}, + expression={"$dayOfWeek": "$date"}, + expected=5, + msg="$dayOfWeek should return 5 for the epoch as a DatetimeMS", + ), + ExpressionTestCase( + "date_ms_before_epoch", + doc={"date": DATE_MS_BEFORE_EPOCH}, + expression={"$dayOfWeek": "$date"}, + expected=4, + msg="$dayOfWeek should return 4 for a DatetimeMS one millisecond before the epoch", + ), + ExpressionTestCase( + "date_ms_year_10000", + doc={"date": DATE_MS_YEAR_10000}, + expression={"$dayOfWeek": "$date"}, + expected=7, + msg="$dayOfWeek should return 7 for a DatetimeMS at the year-10000 boundary", + ), + ExpressionTestCase( + "date_ms_max", + doc={"date": DATE_MS_MAX}, + expression={"$dayOfWeek": "$date"}, + expected=1, + msg="$dayOfWeek should return 1 for the maximum 64-bit DatetimeMS", + ), + ExpressionTestCase( + "date_ms_min", + doc={"date": DATE_MS_MIN}, + expression={"$dayOfWeek": "$date"}, + expected=1, + msg="$dayOfWeek should return 1 for the minimum 64-bit DatetimeMS", + ), + ExpressionTestCase( + "ts_boundary_max_s32", + doc={"date": TS_MAX_SIGNED32}, + expression={"$dayOfWeek": "$date"}, + expected=3, + msg="$dayOfWeek should return 3 for the max signed 32-bit Timestamp", + ), + ExpressionTestCase( + "ts_boundary_max_u32", + doc={"date": TS_MAX_UNSIGNED32}, + expression={"$dayOfWeek": "$date"}, + expected=1, + msg="$dayOfWeek should return 1 for the max unsigned 32-bit Timestamp", + ), + ExpressionTestCase( + "oid_boundary_max_s32", + doc={"date": OID_MAX_SIGNED32}, + expression={"$dayOfWeek": "$date"}, + expected=3, + msg="$dayOfWeek should return 3 for the max signed 32-bit ObjectId", + ), + ExpressionTestCase( + "oid_boundary_min_s32", + doc={"date": OID_MIN_SIGNED32}, + expression={"$dayOfWeek": "$date"}, + expected=6, + msg="$dayOfWeek should return 6 for the min signed 32-bit ObjectId", + ), + ExpressionTestCase( + "oid_boundary_max_u32", + doc={"date": OID_MAX_UNSIGNED32}, + expression={"$dayOfWeek": "$date"}, + expected=4, + msg="$dayOfWeek should return 4 for the max unsigned 32-bit ObjectId", + ), +] + +DAYOFWEEK_DATE_TYPES_TESTS: list[ExpressionTestCase] = ( + DAYOFWEEK_TIMESTAMP_TESTS + DAYOFWEEK_OBJECTID_TESTS + DAYOFWEEK_EXTENDED_RANGE_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DAYOFWEEK_DATE_TYPES_TESTS)) +def test_dayOfWeek_date_types(collection, test_case: ExpressionTestCase): + """Test $dayOfWeek with Timestamp, ObjectId, and extended-range date inputs.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfWeek/test_dayOfWeek_expressions.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfWeek/test_dayOfWeek_expressions.py new file mode 100644 index 000000000..d328ce5db --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfWeek/test_dayOfWeek_expressions.py @@ -0,0 +1,160 @@ +"""Tests for $dayOfWeek argument forms, field-path resolution, and expression input.""" + +from datetime import datetime, timezone + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + ISO_DATE_INVALID_ARRAY_INPUT_ERROR, + ISO_DATE_MISSING_DATE_ERROR, + ISO_DATE_UNKNOWN_FIELD_ERROR, + TYPE_MISMATCH_DATE_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Literal Input]: an inline literal date computes the correct day of the week. +DAYOFWEEK_LITERAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "literal_date", + expression={"$dayOfWeek": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expected=7, + msg="$dayOfWeek should return the day of the week for a literal date operand", + ), +] + +# Property [Argument Forms]: the document form requires exactly a date field, and the +# operand-array form accepts only a single element. +DAYOFWEEK_ARGUMENT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "missing_date", + expression={"$dayOfWeek": {"timezone": "UTC"}}, + error_code=ISO_DATE_MISSING_DATE_ERROR, + msg="$dayOfWeek should error when the document form omits the date field", + ), + ExpressionTestCase( + "extra_field", + expression={ + "$dayOfWeek": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "extra": 1, + } + }, + error_code=ISO_DATE_UNKNOWN_FIELD_ERROR, + msg="$dayOfWeek should error for an unknown field in the document form", + ), + ExpressionTestCase( + "empty_array", + expression={"$dayOfWeek": []}, + error_code=ISO_DATE_INVALID_ARRAY_INPUT_ERROR, + msg="$dayOfWeek should error for an empty operand array", + ), + ExpressionTestCase( + "two_element_array", + expression={ + "$dayOfWeek": [ + datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + ] + }, + error_code=ISO_DATE_INVALID_ARRAY_INPUT_ERROR, + msg="$dayOfWeek should error for a two-element operand array", + ), + ExpressionTestCase( + "single_element_array", + expression={"$dayOfWeek": [datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)]}, + expected=7, + msg="$dayOfWeek should accept a single-element operand array as the date", + ), + ExpressionTestCase( + "object_expression_input", + doc={"date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dayOfWeek": {"a": "$date"}}, + error_code=ISO_DATE_UNKNOWN_FIELD_ERROR, + msg="$dayOfWeek should treat an object with an unknown key as an invalid document form", + ), +] + +# Property [Field-Path Resolution]: the operator accepts a resolved field reference; a path +# that resolves to an array (array-index or array-of-objects) feeds the type contract and errors. +DAYOFWEEK_FIELD_PATH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_field_path", + doc={"a": {"b": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}}, + expression={"$dayOfWeek": "$a.b"}, + expected=7, + msg="$dayOfWeek should accept a date resolved from a nested field path", + ), + ExpressionTestCase( + "array_index_path", + doc={"a": [{"b": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}]}, + expression={"$dayOfWeek": "$a.0.b"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfWeek should error when an array-index path resolves to an array", + ), + ExpressionTestCase( + "composite_array_path", + doc={ + "a": [ + {"b": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + {"b": datetime(2024, 7, 15, 0, 0, 0, tzinfo=timezone.utc)}, + ] + }, + expression={"$dayOfWeek": "$a.b"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfWeek should error when a path over an array of objects resolves to an array", + ), + ExpressionTestCase( + "timezone_from_field", + doc={"date": datetime(2024, 7, 1, 3, 0, 0, tzinfo=timezone.utc), "tz": "America/New_York"}, + expression={"$dayOfWeek": {"date": "$date", "timezone": "$tz"}}, + expected=1, + msg="$dayOfWeek should apply a timezone resolved from a field reference", + ), + ExpressionTestCase( + "missing_tz_field_ref", + doc={"date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dayOfWeek": {"date": "$date", "timezone": "$tz"}}, + expected=None, + msg="$dayOfWeek should return null when the timezone field reference is missing", + ), + ExpressionTestCase( + "expression_as_input", + expression={"$dayOfWeek": {"$dateFromString": {"dateString": "2024-06-15T00:00:00Z"}}}, + expected=7, + msg="$dayOfWeek should accept the result of a nested expression as the date", + ), +] + +# Property [Return Type]: $dayOfWeek returns a value of BSON type int. +DAYOFWEEK_RETURN_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "return_type", + doc={"date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$type": {"$dayOfWeek": "$date"}}, + expected="int", + msg="$dayOfWeek should return an int", + ), +] + +DAYOFWEEK_EXPRESSION_TESTS: list[ExpressionTestCase] = ( + DAYOFWEEK_LITERAL_TESTS + + DAYOFWEEK_ARGUMENT_TESTS + + DAYOFWEEK_FIELD_PATH_TESTS + + DAYOFWEEK_RETURN_TYPE_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DAYOFWEEK_EXPRESSION_TESTS)) +def test_dayOfWeek_expressions(collection, test_case: ExpressionTestCase): + """Test $dayOfWeek argument forms, field-path resolution, expression input, and return type.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfWeek/test_dayOfWeek_null_and_type_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfWeek/test_dayOfWeek_null_and_type_errors.py new file mode 100644 index 000000000..12dbbe4c7 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfWeek/test_dayOfWeek_null_and_type_errors.py @@ -0,0 +1,211 @@ +"""Tests for $dayOfWeek null propagation and non-date type rejection.""" + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, Regex + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import TYPE_MISMATCH_DATE_ERROR +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + MISSING, +) + +# Property [Null Propagation]: a null or missing date resolves to null rather than an error. +DAYOFWEEK_NULL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_date", + doc={"date": None}, + expression={"$dayOfWeek": "$date"}, + expected=None, + msg="$dayOfWeek should return null for a null date", + ), + ExpressionTestCase( + "missing_date", + expression={"$dayOfWeek": MISSING}, + expected=None, + msg="$dayOfWeek should return null when the date references a missing field", + ), +] + +# Property [Type Rejection]: any non-date input type is rejected with a type-mismatch error. +DAYOFWEEK_TYPE_REJECTION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "string_date", + doc={"date": "not-a-date"}, + expression={"$dayOfWeek": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfWeek should reject a string as the date input", + ), + ExpressionTestCase( + "integer_date", + doc={"date": 42}, + expression={"$dayOfWeek": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfWeek should reject an int as the date input", + ), + ExpressionTestCase( + "int64_date", + doc={"date": Int64(42)}, + expression={"$dayOfWeek": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfWeek should reject an int64 as the date input", + ), + ExpressionTestCase( + "double_date", + doc={"date": 3.14}, + expression={"$dayOfWeek": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfWeek should reject a double as the date input", + ), + ExpressionTestCase( + "decimal128_date", + doc={"date": Decimal128("42")}, + expression={"$dayOfWeek": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfWeek should reject a decimal128 as the date input", + ), + ExpressionTestCase( + "boolean_date", + doc={"date": True}, + expression={"$dayOfWeek": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfWeek should reject a boolean as the date input", + ), + ExpressionTestCase( + "array_date", + doc={"date": [1, 2, 3]}, + expression={"$dayOfWeek": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfWeek should reject an array as the date input", + ), + ExpressionTestCase( + "object_date", + doc={"date": {"a": 1}}, + expression={"$dayOfWeek": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfWeek should reject an object as the date input", + ), + ExpressionTestCase( + "empty_string_date", + doc={"date": ""}, + expression={"$dayOfWeek": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfWeek should reject an empty string as the date input", + ), + ExpressionTestCase( + "empty_array_date", + doc={"date": []}, + expression={"$dayOfWeek": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfWeek should reject an empty array as the date input", + ), + ExpressionTestCase( + "empty_object_date", + doc={"date": {}}, + expression={"$dayOfWeek": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfWeek should reject an empty object as the date input", + ), + ExpressionTestCase( + "float_nan_date", + doc={"date": FLOAT_NAN}, + expression={"$dayOfWeek": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfWeek should reject a float NaN as the date input", + ), + ExpressionTestCase( + "decimal128_nan_date", + doc={"date": DECIMAL128_NAN}, + expression={"$dayOfWeek": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfWeek should reject a decimal128 NaN as the date input", + ), + ExpressionTestCase( + "regex_date", + doc={"date": Regex(".*")}, + expression={"$dayOfWeek": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfWeek should reject a regex as the date input", + ), + ExpressionTestCase( + "minkey_date", + doc={"date": MinKey()}, + expression={"$dayOfWeek": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfWeek should reject MinKey as the date input", + ), + ExpressionTestCase( + "maxkey_date", + doc={"date": MaxKey()}, + expression={"$dayOfWeek": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfWeek should reject MaxKey as the date input", + ), + ExpressionTestCase( + "float_inf_date", + doc={"date": FLOAT_INFINITY}, + expression={"$dayOfWeek": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfWeek should reject a float infinity as the date input", + ), + ExpressionTestCase( + "float_neg_inf_date", + doc={"date": FLOAT_NEGATIVE_INFINITY}, + expression={"$dayOfWeek": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfWeek should reject a float negative infinity as the date input", + ), + ExpressionTestCase( + "decimal128_inf_date", + doc={"date": DECIMAL128_INFINITY}, + expression={"$dayOfWeek": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfWeek should reject a decimal128 infinity as the date input", + ), + ExpressionTestCase( + "decimal128_neg_inf_date", + doc={"date": DECIMAL128_NEGATIVE_INFINITY}, + expression={"$dayOfWeek": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfWeek should reject a decimal128 negative infinity as the date input", + ), + ExpressionTestCase( + "bindata_date", + doc={"date": Binary(b"")}, + expression={"$dayOfWeek": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfWeek should reject binary data as the date input", + ), + ExpressionTestCase( + "javascript_date", + doc={"date": Code("function{}")}, + expression={"$dayOfWeek": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfWeek should reject JavaScript code as the date input", + ), +] + +DAYOFWEEK_NULL_AND_TYPE_ERROR_TESTS: list[ExpressionTestCase] = ( + DAYOFWEEK_NULL_TESTS + DAYOFWEEK_TYPE_REJECTION_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DAYOFWEEK_NULL_AND_TYPE_ERROR_TESTS)) +def test_dayOfWeek_null_and_type_errors(collection, test_case: ExpressionTestCase): + """Test $dayOfWeek null propagation and non-date type rejection.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfWeek/test_dayOfWeek_timezone_input_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfWeek/test_dayOfWeek_timezone_input_types.py new file mode 100644 index 000000000..8bbf92363 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfWeek/test_dayOfWeek_timezone_input_types.py @@ -0,0 +1,224 @@ +"""Tests for $dayOfWeek timezone application when the date is a Timestamp or ObjectId.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.date_utils import ( + oid_from_args, + ts_from_args, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Timestamp Input with Zones]: a Timestamp input honours the zone offset. +DAYOFWEEK_TIMESTAMP_ZONE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_olson_ts_utc_no_cross", + expression={"$dayOfWeek": {"date": ts_from_args(2024, 7, 15, 12, 0, 0), "timezone": "UTC"}}, + expected=2, + msg="$dayOfWeek should return 2 for a Timestamp in UTC with no crossing", + ), + ExpressionTestCase( + "tz_olson_ts_tokyo_fwd", + expression={ + "$dayOfWeek": {"date": ts_from_args(2024, 6, 30, 22, 0, 0), "timezone": "Asia/Tokyo"} + }, + expected=2, + msg="$dayOfWeek should cross forward to Monday for a Timestamp in Asia/Tokyo", + ), + ExpressionTestCase( + "tz_olson_ts_la_bwd", + expression={ + "$dayOfWeek": { + "date": ts_from_args(2024, 7, 1, 3, 0, 0), + "timezone": "America/Los_Angeles", + } + }, + expected=1, + msg="$dayOfWeek should cross backward to Sunday for a Timestamp in America/Los_Angeles", + ), + ExpressionTestCase( + "tz_olson_ts_kolkata_fwd", + expression={ + "$dayOfWeek": {"date": ts_from_args(2024, 3, 31, 20, 0, 0), "timezone": "Asia/Kolkata"} + }, + expected=2, + msg="$dayOfWeek should cross forward to Monday for a Timestamp in Asia/Kolkata", + ), + ExpressionTestCase( + "tz_olson_ts_ny_year_bwd", + expression={ + "$dayOfWeek": { + "date": ts_from_args(2024, 1, 1, 3, 0, 0), + "timezone": "America/New_York", + } + }, + expected=1, + msg="$dayOfWeek should cross the year boundary backward for a Timestamp in New York", + ), + ExpressionTestCase( + "tz_olson_ts_helsinki_year_fwd", + expression={ + "$dayOfWeek": { + "date": ts_from_args(2024, 12, 31, 23, 0, 0), + "timezone": "Europe/Helsinki", + } + }, + expected=4, + msg="$dayOfWeek should cross the year boundary forward for a Timestamp in Europe/Helsinki", + ), + ExpressionTestCase( + "tz_offset_ts_plus9_fwd", + expression={ + "$dayOfWeek": {"date": ts_from_args(2024, 6, 30, 22, 0, 0), "timezone": "+09:00"} + }, + expected=2, + msg="$dayOfWeek should cross forward to Monday for a Timestamp at a +09:00 offset", + ), + ExpressionTestCase( + "tz_offset_ts_minus8_bwd", + expression={ + "$dayOfWeek": {"date": ts_from_args(2024, 7, 1, 3, 0, 0), "timezone": "-08:00"} + }, + expected=1, + msg="$dayOfWeek should cross backward to Sunday for a Timestamp at a -08:00 offset", + ), + ExpressionTestCase( + "tz_offset_ts_plus530_fwd", + expression={ + "$dayOfWeek": {"date": ts_from_args(2024, 3, 31, 20, 0, 0), "timezone": "+05:30"} + }, + expected=2, + msg="$dayOfWeek should cross forward to Monday for a Timestamp at a +05:30 offset", + ), + ExpressionTestCase( + "tz_offset_ts_plus545_fwd", + expression={ + "$dayOfWeek": {"date": ts_from_args(2024, 8, 31, 23, 0, 0), "timezone": "+05:45"} + }, + expected=1, + msg="$dayOfWeek should cross forward to Sunday for a Timestamp at a +05:45 offset", + ), + ExpressionTestCase( + "tz_offset_ts_plus3_leap_fwd", + expression={ + "$dayOfWeek": {"date": ts_from_args(2024, 2, 29, 23, 30, 0), "timezone": "+03:00"} + }, + expected=6, + msg="$dayOfWeek should cross the leap-day boundary forward for a Timestamp at +03:00", + ), +] + +# Property [ObjectId Input with Zones]: an ObjectId input honours the zone offset. +DAYOFWEEK_OBJECTID_ZONE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_olson_oid_utc_no_cross", + expression={ + "$dayOfWeek": {"date": oid_from_args(2024, 7, 15, 12, 0, 0), "timezone": "UTC"} + }, + expected=2, + msg="$dayOfWeek should return 2 for an ObjectId in UTC with no crossing", + ), + ExpressionTestCase( + "tz_olson_oid_tokyo_fwd", + expression={ + "$dayOfWeek": {"date": oid_from_args(2024, 6, 30, 22, 0, 0), "timezone": "Asia/Tokyo"} + }, + expected=2, + msg="$dayOfWeek should cross forward to Monday for an ObjectId in Asia/Tokyo", + ), + ExpressionTestCase( + "tz_olson_oid_la_bwd", + expression={ + "$dayOfWeek": { + "date": oid_from_args(2024, 7, 1, 3, 0, 0), + "timezone": "America/Los_Angeles", + } + }, + expected=1, + msg="$dayOfWeek should cross backward to Sunday for an ObjectId in America/Los_Angeles", + ), + ExpressionTestCase( + "tz_olson_oid_kolkata_fwd", + expression={ + "$dayOfWeek": { + "date": oid_from_args(2024, 3, 31, 20, 0, 0), + "timezone": "Asia/Kolkata", + } + }, + expected=2, + msg="$dayOfWeek should cross forward to Monday for an ObjectId in Asia/Kolkata", + ), + ExpressionTestCase( + "tz_olson_oid_ny_year_bwd", + expression={ + "$dayOfWeek": { + "date": oid_from_args(2024, 1, 1, 3, 0, 0), + "timezone": "America/New_York", + } + }, + expected=1, + msg="$dayOfWeek should cross the year boundary backward for an ObjectId in New York", + ), + ExpressionTestCase( + "tz_olson_oid_helsinki_year_fwd", + expression={ + "$dayOfWeek": { + "date": oid_from_args(2024, 12, 31, 23, 0, 0), + "timezone": "Europe/Helsinki", + } + }, + expected=4, + msg="$dayOfWeek should cross the year boundary forward for an ObjectId in Europe/Helsinki", + ), + ExpressionTestCase( + "tz_offset_oid_plus9_fwd", + expression={ + "$dayOfWeek": {"date": oid_from_args(2024, 6, 30, 22, 0, 0), "timezone": "+09:00"} + }, + expected=2, + msg="$dayOfWeek should cross forward to Monday for an ObjectId at a +09:00 offset", + ), + ExpressionTestCase( + "tz_offset_oid_minus8_bwd", + expression={ + "$dayOfWeek": {"date": oid_from_args(2024, 7, 1, 3, 0, 0), "timezone": "-08:00"} + }, + expected=1, + msg="$dayOfWeek should cross backward to Sunday for an ObjectId at a -08:00 offset", + ), + ExpressionTestCase( + "tz_offset_oid_plus530_fwd", + expression={ + "$dayOfWeek": {"date": oid_from_args(2024, 3, 31, 20, 0, 0), "timezone": "+05:30"} + }, + expected=2, + msg="$dayOfWeek should cross forward to Monday for an ObjectId at a +05:30 offset", + ), + ExpressionTestCase( + "tz_offset_oid_plus3_leap_fwd", + expression={ + "$dayOfWeek": {"date": oid_from_args(2024, 2, 29, 23, 30, 0), "timezone": "+03:00"} + }, + expected=6, + msg="$dayOfWeek should cross the leap-day boundary forward for an ObjectId at +03:00", + ), +] + +DAYOFWEEK_TIMEZONE_INPUT_TYPES_TESTS: list[ExpressionTestCase] = ( + DAYOFWEEK_TIMESTAMP_ZONE_TESTS + DAYOFWEEK_OBJECTID_ZONE_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DAYOFWEEK_TIMEZONE_INPUT_TYPES_TESTS)) +def test_dayOfWeek_timezone_input_types(collection, test_case: ExpressionTestCase): + """Test $dayOfWeek timezone application for Timestamp and ObjectId date inputs.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfWeek/test_dayOfWeek_timezone_names.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfWeek/test_dayOfWeek_timezone_names.py new file mode 100644 index 000000000..08b813da0 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfWeek/test_dayOfWeek_timezone_names.py @@ -0,0 +1,237 @@ +"""Tests for $dayOfWeek named-timezone application, including DST and abbreviations.""" + +from datetime import datetime, timezone + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Named Zones]: a named zone or abbreviation shifts the instant before the day of +# week is taken, which may cross a day, month, year, or leap boundary depending on offset and DST. +DAYOFWEEK_OLSON_DATETIME_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_olson_dt_utc_no_cross", + expression={ + "$dayOfWeek": { + "date": datetime(2024, 7, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "UTC", + } + }, + expected=2, + msg="$dayOfWeek should return 2 for UTC with no day crossing", + ), + ExpressionTestCase( + "tz_olson_dt_ny_no_cross", + expression={ + "$dayOfWeek": { + "date": datetime(2024, 7, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "America/New_York", + } + }, + expected=2, + msg="$dayOfWeek should return 2 for America/New_York with no day crossing", + ), + ExpressionTestCase( + "tz_olson_dt_tokyo_no_cross", + expression={ + "$dayOfWeek": { + "date": datetime(2024, 7, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "Asia/Tokyo", + } + }, + expected=2, + msg="$dayOfWeek should return 2 for Asia/Tokyo with no day crossing", + ), + ExpressionTestCase( + "tz_olson_dt_tokyo_fwd", + expression={ + "$dayOfWeek": { + "date": datetime(2024, 6, 30, 22, 0, 0, tzinfo=timezone.utc), + "timezone": "Asia/Tokyo", + } + }, + expected=2, + msg="$dayOfWeek should cross forward from Sunday to Monday for Asia/Tokyo (+09:00)", + ), + ExpressionTestCase( + "tz_olson_dt_la_bwd", + expression={ + "$dayOfWeek": { + "date": datetime(2024, 7, 1, 3, 0, 0, tzinfo=timezone.utc), + "timezone": "America/Los_Angeles", + } + }, + expected=1, + msg="$dayOfWeek should cross backward from Monday to Sunday for America/Los_Angeles (PDT)", + ), + ExpressionTestCase( + "tz_olson_dt_denver_bwd", + expression={ + "$dayOfWeek": { + "date": datetime(2024, 11, 1, 5, 0, 0, tzinfo=timezone.utc), + "timezone": "America/Denver", + } + }, + expected=5, + msg="$dayOfWeek should cross backward from Friday to Thursday for America/Denver (MDT)", + ), + ExpressionTestCase( + "tz_olson_dt_kolkata_fwd", + expression={ + "$dayOfWeek": { + "date": datetime(2024, 3, 31, 20, 0, 0, tzinfo=timezone.utc), + "timezone": "Asia/Kolkata", + } + }, + expected=2, + msg="$dayOfWeek should cross forward from Sunday to Monday for Asia/Kolkata (+05:30)", + ), + ExpressionTestCase( + "tz_olson_dt_kathmandu_fwd", + expression={ + "$dayOfWeek": { + "date": datetime(2024, 8, 31, 23, 0, 0, tzinfo=timezone.utc), + "timezone": "Asia/Kathmandu", + } + }, + expected=1, + msg="$dayOfWeek should cross forward from Saturday to Sunday for Asia/Kathmandu (+05:45)", + ), + ExpressionTestCase( + "tz_olson_dt_ny_year_bwd", + expression={ + "$dayOfWeek": { + "date": datetime(2024, 1, 1, 3, 0, 0, tzinfo=timezone.utc), + "timezone": "America/New_York", + } + }, + expected=1, + msg="$dayOfWeek should cross the year boundary backward to Sunday for America/New_York", + ), + ExpressionTestCase( + "tz_olson_dt_ny_year_stays", + expression={ + "$dayOfWeek": { + "date": datetime(2024, 1, 1, 6, 0, 0, tzinfo=timezone.utc), + "timezone": "America/New_York", + } + }, + expected=2, + msg="$dayOfWeek should stay on Monday for America/New_York after the year boundary", + ), + ExpressionTestCase( + "tz_olson_dt_helsinki_year_fwd", + expression={ + "$dayOfWeek": { + "date": datetime(2024, 12, 31, 23, 0, 0, tzinfo=timezone.utc), + "timezone": "Europe/Helsinki", + } + }, + expected=4, + msg="$dayOfWeek should cross the year boundary forward to Wednesday for Europe/Helsinki", + ), + ExpressionTestCase( + "tz_olson_dt_moscow_leap_fwd", + expression={ + "$dayOfWeek": { + "date": datetime(2024, 2, 29, 23, 30, 0, tzinfo=timezone.utc), + "timezone": "Europe/Moscow", + } + }, + expected=6, + msg="$dayOfWeek should cross the leap-day boundary forward to Friday for Europe/Moscow", + ), + ExpressionTestCase( + "tz_olson_dt_ny_leap_bwd", + expression={ + "$dayOfWeek": { + "date": datetime(2024, 3, 1, 2, 0, 0, tzinfo=timezone.utc), + "timezone": "America/New_York", + } + }, + expected=5, + msg="$dayOfWeek should cross backward to leap-day Thursday for America/New_York", + ), + ExpressionTestCase( + "tz_olson_dt_ny_dst_spring", + expression={ + "$dayOfWeek": { + "date": datetime(2024, 3, 10, 7, 0, 0, tzinfo=timezone.utc), + "timezone": "America/New_York", + } + }, + expected=1, + msg="$dayOfWeek should return 1 across the spring DST transition in America/New_York", + ), + ExpressionTestCase( + "tz_olson_dt_ny_dst_fall", + expression={ + "$dayOfWeek": { + "date": datetime(2024, 11, 3, 6, 0, 0, tzinfo=timezone.utc), + "timezone": "America/New_York", + } + }, + expected=1, + msg="$dayOfWeek should return 1 across the fall DST transition in America/New_York", + ), + ExpressionTestCase( + "tz_olson_dt_london_bst_start", + expression={ + "$dayOfWeek": { + "date": datetime(2024, 3, 31, 0, 30, 0, tzinfo=timezone.utc), + "timezone": "Europe/London", + } + }, + expected=1, + msg="$dayOfWeek should return 1 at the Europe/London BST start", + ), + ExpressionTestCase( + "tz_olson_dt_london_bst", + expression={ + "$dayOfWeek": { + "date": datetime(2020, 6, 30, 23, 30, 0, tzinfo=timezone.utc), + "timezone": "Europe/London", + } + }, + expected=4, + msg="$dayOfWeek should cross forward to Wednesday for Europe/London in summer (BST)", + ), + ExpressionTestCase( + "tz_olson_dt_pacific_apia", + expression={ + "$dayOfWeek": { + "date": datetime(2024, 6, 30, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "Pacific/Apia", + } + }, + expected=2, + msg="$dayOfWeek should cross forward to Monday for Pacific/Apia (+13:00)", + ), + ExpressionTestCase( + "tz_est_abbreviation", + expression={ + "$dayOfWeek": { + "date": datetime(2024, 7, 1, 3, 0, 0, tzinfo=timezone.utc), + "timezone": "EST", + } + }, + expected=1, + msg="$dayOfWeek should accept the EST three-letter abbreviation", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(DAYOFWEEK_OLSON_DATETIME_TESTS)) +def test_dayOfWeek_timezone_names(collection, test_case: ExpressionTestCase): + """Test $dayOfWeek named-timezone application across zones, DST, and abbreviations.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfWeek/test_dayOfWeek_timezone_offsets.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfWeek/test_dayOfWeek_timezone_offsets.py new file mode 100644 index 000000000..b754326ab --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfWeek/test_dayOfWeek_timezone_offsets.py @@ -0,0 +1,260 @@ +"""Tests for $dayOfWeek UTC-offset timezone application, including edge and unusual offsets.""" + +from datetime import datetime, timezone + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [UTC Offsets, datetime]: an explicit +HH:MM/-HH:MM offset shifts the instant, +# including half/quarter-hour, extreme, and out-of-range offsets the server still accepts. +DAYOFWEEK_OFFSET_DATETIME_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_offset_dt_plus0_no_cross", + expression={ + "$dayOfWeek": { + "date": datetime(2024, 7, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "+00:00", + } + }, + expected=2, + msg="$dayOfWeek should return 2 for a +00:00 offset with no crossing", + ), + ExpressionTestCase( + "tz_offset_dt_minus5_no_cross", + expression={ + "$dayOfWeek": { + "date": datetime(2024, 7, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "-05:00", + } + }, + expected=2, + msg="$dayOfWeek should return 2 for a -05:00 offset with no crossing", + ), + ExpressionTestCase( + "tz_offset_dt_plus9_fwd", + expression={ + "$dayOfWeek": { + "date": datetime(2024, 6, 30, 22, 0, 0, tzinfo=timezone.utc), + "timezone": "+09:00", + } + }, + expected=2, + msg="$dayOfWeek should cross forward from Sunday to Monday for a +09:00 offset", + ), + ExpressionTestCase( + "tz_offset_dt_minus8_bwd", + expression={ + "$dayOfWeek": { + "date": datetime(2024, 7, 1, 3, 0, 0, tzinfo=timezone.utc), + "timezone": "-08:00", + } + }, + expected=1, + msg="$dayOfWeek should cross backward from Monday to Sunday for a -08:00 offset", + ), + ExpressionTestCase( + "tz_offset_dt_plus530_fwd", + expression={ + "$dayOfWeek": { + "date": datetime(2024, 3, 31, 20, 0, 0, tzinfo=timezone.utc), + "timezone": "+05:30", + } + }, + expected=2, + msg="$dayOfWeek should cross forward from Sunday to Monday for a +05:30 half-hour offset", + ), + ExpressionTestCase( + "tz_offset_dt_plus545_fwd", + expression={ + "$dayOfWeek": { + "date": datetime(2024, 8, 31, 23, 0, 0, tzinfo=timezone.utc), + "timezone": "+05:45", + } + }, + expected=1, + msg="$dayOfWeek should cross forward from Saturday to Sunday for a +05:45 quarter-hour " + "offset", + ), + ExpressionTestCase( + "tz_offset_dt_minus5_year_bwd", + expression={ + "$dayOfWeek": { + "date": datetime(2024, 1, 1, 3, 0, 0, tzinfo=timezone.utc), + "timezone": "-05:00", + } + }, + expected=1, + msg="$dayOfWeek should cross the year boundary backward to Sunday for a -05:00 offset", + ), + ExpressionTestCase( + "tz_offset_dt_plus2_year_fwd", + expression={ + "$dayOfWeek": { + "date": datetime(2024, 12, 31, 23, 0, 0, tzinfo=timezone.utc), + "timezone": "+02:00", + } + }, + expected=4, + msg="$dayOfWeek should cross the year boundary forward to Wednesday for a +02:00 offset", + ), + ExpressionTestCase( + "tz_offset_dt_plus3_leap_fwd", + expression={ + "$dayOfWeek": { + "date": datetime(2024, 2, 29, 23, 30, 0, tzinfo=timezone.utc), + "timezone": "+03:00", + } + }, + expected=6, + msg="$dayOfWeek should cross the leap-day boundary forward to Friday for a +03:00 offset", + ), + ExpressionTestCase( + "tz_offset_dt_minus5_leap_bwd", + expression={ + "$dayOfWeek": { + "date": datetime(2024, 3, 1, 2, 0, 0, tzinfo=timezone.utc), + "timezone": "-05:00", + } + }, + expected=5, + msg="$dayOfWeek should cross backward to leap-day Thursday for a -05:00 offset", + ), + ExpressionTestCase( + "tz_offset_dt_plus14_fwd", + expression={ + "$dayOfWeek": { + "date": datetime(2024, 6, 30, 11, 0, 0, tzinfo=timezone.utc), + "timezone": "+14:00", + } + }, + expected=2, + msg="$dayOfWeek should cross forward to Monday for the extreme +14:00 offset", + ), + ExpressionTestCase( + "tz_offset_dt_minus12_bwd", + expression={ + "$dayOfWeek": { + "date": datetime(2024, 7, 1, 11, 0, 0, tzinfo=timezone.utc), + "timezone": "-12:00", + } + }, + expected=1, + msg="$dayOfWeek should cross backward to Sunday for the extreme -12:00 offset", + ), + ExpressionTestCase( + "tz_offset_no_colon", + expression={ + "$dayOfWeek": { + "date": datetime(2024, 7, 1, 3, 0, 0, tzinfo=timezone.utc), + "timezone": "-0500", + } + }, + expected=1, + msg="$dayOfWeek should accept a compact -0500 offset without a colon", + ), + ExpressionTestCase( + "tz_offset_hour_only", + expression={ + "$dayOfWeek": { + "date": datetime(2024, 7, 1, 3, 0, 0, tzinfo=timezone.utc), + "timezone": "-08", + } + }, + expected=1, + msg="$dayOfWeek should accept an hour-only -08 offset", + ), + ExpressionTestCase( + "tz_offset_minus13", + expression={ + "$dayOfWeek": { + "date": datetime(2024, 7, 1, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "-13:00", + } + }, + expected=1, + msg="$dayOfWeek should accept a -13:00 offset", + ), + ExpressionTestCase( + "tz_offset_plus15", + expression={ + "$dayOfWeek": { + "date": datetime(2024, 6, 30, 10, 0, 0, tzinfo=timezone.utc), + "timezone": "+15:00", + } + }, + expected=2, + msg="$dayOfWeek should accept a +15:00 offset", + ), + ExpressionTestCase( + "tz_offset_minus0330", + expression={ + "$dayOfWeek": { + "date": datetime(2024, 7, 1, 2, 0, 0, tzinfo=timezone.utc), + "timezone": "-03:30", + } + }, + expected=1, + msg="$dayOfWeek should accept a -03:30 half-hour west offset", + ), + ExpressionTestCase( + "tz_offset_plus0570", + expression={ + "$dayOfWeek": { + "date": datetime(2024, 6, 30, 17, 0, 0, tzinfo=timezone.utc), + "timezone": "+05:70", + } + }, + expected=1, + msg="$dayOfWeek should accept a +05:70 (70-minute) offset", + ), + ExpressionTestCase( + "tz_offset_minus0570", + expression={ + "$dayOfWeek": { + "date": datetime(2024, 7, 1, 5, 0, 0, tzinfo=timezone.utc), + "timezone": "-05:70", + } + }, + expected=1, + msg="$dayOfWeek should accept a -05:70 (70-minute) offset", + ), + ExpressionTestCase( + "tz_offset_plus2500", + expression={ + "$dayOfWeek": { + "date": datetime(2024, 6, 29, 0, 0, 0, tzinfo=timezone.utc), + "timezone": "+25:00", + } + }, + expected=1, + msg="$dayOfWeek should accept a +25:00 (25-hour) offset", + ), + ExpressionTestCase( + "tz_offset_minus2500", + expression={ + "$dayOfWeek": { + "date": datetime(2024, 7, 2, 0, 0, 0, tzinfo=timezone.utc), + "timezone": "-25:00", + } + }, + expected=1, + msg="$dayOfWeek should accept a -25:00 (25-hour) offset", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(DAYOFWEEK_OFFSET_DATETIME_TESTS)) +def test_dayOfWeek_timezone_offsets(collection, test_case: ExpressionTestCase): + """Test $dayOfWeek UTC-offset timezone application, including edge and unusual offsets.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfWeek/test_dayOfWeek_timezone_validation.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfWeek/test_dayOfWeek_timezone_validation.py new file mode 100644 index 000000000..27e07379a --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfWeek/test_dayOfWeek_timezone_validation.py @@ -0,0 +1,151 @@ +"""Tests for $dayOfWeek timezone validation and null-timezone propagation.""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + INVALID_TIMEZONE_ERROR, + INVALID_TIMEZONE_TYPE_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Timezone Validation]: unparseable zone strings, wrong-typed timezones, and +# null timezones are rejected or propagate null. +DAYOFWEEK_TIMEZONE_VALIDATION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "invalid_tz_olson", + expression={ + "$dayOfWeek": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "Not/A_Timezone", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$dayOfWeek should reject an unparseable Olson-like timezone string", + ), + ExpressionTestCase( + "invalid_tz_nonexistent_olson", + expression={ + "$dayOfWeek": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "America/Nowhere", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$dayOfWeek should reject a non-existent Olson timezone", + ), + ExpressionTestCase( + "invalid_tz_offset_format", + expression={ + "$dayOfWeek": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "25:00", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$dayOfWeek should reject a bare 25:00 offset without a sign", + ), + ExpressionTestCase( + "invalid_tz_numeric", + expression={ + "$dayOfWeek": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "123", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$dayOfWeek should reject a numeric-string timezone", + ), + ExpressionTestCase( + "invalid_tz_empty_string", + expression={ + "$dayOfWeek": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$dayOfWeek should reject an empty-string timezone", + ), + ExpressionTestCase( + "invalid_tz_olson_lowercase", + expression={ + "$dayOfWeek": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "america/new_york", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$dayOfWeek should reject an all-lowercase Olson name", + ), + ExpressionTestCase( + "invalid_tz_olson_uppercase", + expression={ + "$dayOfWeek": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "AMERICA/NEW_YORK", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$dayOfWeek should reject an all-uppercase Olson name", + ), + *[ + ExpressionTestCase( + f"invalid_tz_{tid}", + expression={ + "$dayOfWeek": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": val, + } + }, + error_code=INVALID_TIMEZONE_TYPE_ERROR, + msg=f"$dayOfWeek should reject a {tid} timezone", + ) + for tid, val in [ + ("int", 5), + ("int64", Int64(5)), + ("double", 3.14), + ("decimal128", Decimal128("5")), + ("bool", True), + ("object", {"tz": "UTC"}), + ("array", ["UTC"]), + ("datetime", datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] + ], + ExpressionTestCase( + "null_tz", + expression={ + "$dayOfWeek": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": None, + } + }, + expected=None, + msg="$dayOfWeek should return null for a null timezone", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(DAYOFWEEK_TIMEZONE_VALIDATION_TESTS)) +def test_dayOfWeek_timezone_validation(collection, test_case: ExpressionTestCase): + """Test $dayOfWeek timezone validation and null-timezone propagation.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfYear/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfYear/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfYear/test_dayOfYear_calendar.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfYear/test_dayOfYear_calendar.py new file mode 100644 index 000000000..069f44d2e --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfYear/test_dayOfYear_calendar.py @@ -0,0 +1,223 @@ +"""Tests for $dayOfYear day-of-year extraction from datetimes across the calendar and year range.""" + +from datetime import datetime, timezone + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import DATE_EPOCH + +# Property [Day Extraction]: $dayOfYear returns the ordinal day (1-366) of a UTC date. +DAYOFYEAR_EXTRACTION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "day_1", + doc={"date": datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dayOfYear": "$date"}, + expected=1, + msg="$dayOfYear should return 1 for the first day of the year", + ), + ExpressionTestCase( + "day_15", + doc={"date": datetime(2024, 1, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dayOfYear": "$date"}, + expected=15, + msg="$dayOfYear should return 15 for the fifteenth day of the year", + ), + ExpressionTestCase( + "day_60_leap_feb29", + doc={"date": datetime(2024, 2, 29, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dayOfYear": "$date"}, + expected=60, + msg="$dayOfYear should return 60 for Feb 29 in a leap year", + ), + ExpressionTestCase( + "day_167_jun15", + doc={"date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dayOfYear": "$date"}, + expected=167, + msg="$dayOfYear should return the ordinal day for Jun 15 in a leap year", + ), + ExpressionTestCase( + "day_366_dec31_leap", + doc={"date": datetime(2024, 12, 31, 23, 59, 59, tzinfo=timezone.utc)}, + expression={"$dayOfYear": "$date"}, + expected=366, + msg="$dayOfYear should return 366, the maximum ordinal day, for Dec 31 in a leap year", + ), +] + +# Property [Calendar Boundaries]: year edges, leap-year Feb 29, century rules, and +# sub-second precision resolve to the correct ordinal day. +DAYOFYEAR_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "first_moment_of_year", + doc={"date": datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$dayOfYear": "$date"}, + expected=1, + msg="$dayOfYear should return 1 for the first moment of the year", + ), + ExpressionTestCase( + "non_leap_year_feb_28", + doc={"date": datetime(2023, 2, 28, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dayOfYear": "$date"}, + expected=59, + msg="$dayOfYear should return 59 for Feb 28 in a non-leap year", + ), + ExpressionTestCase( + "leap_year_feb_29_2000", + doc={"date": datetime(2000, 2, 29, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dayOfYear": "$date"}, + expected=60, + msg="$dayOfYear should return 60 for Feb 29 in the century leap year 2000", + ), + ExpressionTestCase( + "non_leap_century_1900_feb_28", + doc={"date": datetime(1900, 2, 28, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dayOfYear": "$date"}, + expected=59, + msg="$dayOfYear should return 59 for Feb 28 in the non-leap century year 1900", + ), + ExpressionTestCase( + "non_leap_year_dec_31", + doc={"date": datetime(2023, 12, 31, 23, 59, 59, tzinfo=timezone.utc)}, + expression={"$dayOfYear": "$date"}, + expected=365, + msg="$dayOfYear should return 365 for Dec 31 in a non-leap year", + ), + ExpressionTestCase( + "millisecond_before_next_day", + doc={"date": datetime(2024, 6, 15, 23, 59, 59, 999000, tzinfo=timezone.utc)}, + expression={"$dayOfYear": "$date"}, + expected=167, + msg="$dayOfYear should not advance to the next day one millisecond before it", + ), + ExpressionTestCase( + "millisecond_after_day_start", + doc={"date": datetime(2024, 6, 16, 0, 0, 0, 1000, tzinfo=timezone.utc)}, + expression={"$dayOfYear": "$date"}, + expected=168, + msg="$dayOfYear should advance to the new day one millisecond after it starts", + ), + ExpressionTestCase( + "millisecond_mid_day", + doc={"date": datetime(2024, 6, 15, 12, 30, 30, 500000, tzinfo=timezone.utc)}, + expression={"$dayOfYear": "$date"}, + expected=167, + msg="$dayOfYear should return the day for a mid-day instant with milliseconds", + ), + ExpressionTestCase( + "millisecond_year_boundary", + doc={"date": datetime(2024, 12, 31, 23, 59, 59, 999000, tzinfo=timezone.utc)}, + expression={"$dayOfYear": "$date"}, + expected=366, + msg="$dayOfYear should return 366 one millisecond before the year boundary", + ), + ExpressionTestCase( + "millisecond_leap_feb_end", + doc={"date": datetime(2024, 2, 29, 23, 59, 59, 999000, tzinfo=timezone.utc)}, + expression={"$dayOfYear": "$date"}, + expected=60, + msg="$dayOfYear should return 60 one millisecond before the end of leap-year February", + ), +] + +# Property [Year Range]: the ordinal day is correct across a wide span of years. +DAYOFYEAR_YEAR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "year_2000", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dayOfYear": "$date"}, + expected=1, + msg="$dayOfYear should return 1 for a date in the year 2000", + ), + ExpressionTestCase( + "year_1970_epoch", + doc={"date": DATE_EPOCH}, + expression={"$dayOfYear": "$date"}, + expected=1, + msg="$dayOfYear should return 1 for the Unix epoch", + ), + ExpressionTestCase( + "year_1999", + doc={"date": datetime(1999, 12, 31, 23, 59, 59, tzinfo=timezone.utc)}, + expression={"$dayOfYear": "$date"}, + expected=365, + msg="$dayOfYear should return 365 for the last day of 1999", + ), + ExpressionTestCase( + "year_2099", + doc={"date": datetime(2099, 7, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dayOfYear": "$date"}, + expected=196, + msg="$dayOfYear should return 196 for Jul 15 2099", + ), + ExpressionTestCase( + "year_9999", + doc={"date": datetime(9999, 12, 31, 23, 59, 59, tzinfo=timezone.utc)}, + expression={"$dayOfYear": "$date"}, + expected=365, + msg="$dayOfYear should return 365 for the last day of the last representable year 9999", + ), +] + +# Property [Pre-Epoch]: negative-millisecond dates before 1970 resolve to the correct day. +DAYOFYEAR_PRE_EPOCH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "pre_epoch_1960", + doc={"date": datetime(1960, 3, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dayOfYear": "$date"}, + expected=75, + msg="$dayOfYear should return 75 for a pre-epoch date in 1960", + ), + ExpressionTestCase( + "pre_epoch_1900", + doc={"date": datetime(1900, 7, 4, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$dayOfYear": "$date"}, + expected=185, + msg="$dayOfYear should return 185 for a pre-epoch date in 1900", + ), + ExpressionTestCase( + "pre_epoch_1969_dec", + doc={"date": datetime(1969, 12, 31, 23, 59, 59, tzinfo=timezone.utc)}, + expression={"$dayOfYear": "$date"}, + expected=365, + msg="$dayOfYear should return 365 for the last day before the epoch", + ), + ExpressionTestCase( + "pre_epoch_1969_jan", + doc={"date": datetime(1969, 1, 1, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$dayOfYear": "$date"}, + expected=1, + msg="$dayOfYear should return 1 for the first day of 1969", + ), + ExpressionTestCase( + "pre_epoch_1950_leap", + doc={"date": datetime(1952, 2, 29, 6, 0, 0, tzinfo=timezone.utc)}, + expression={"$dayOfYear": "$date"}, + expected=60, + msg="$dayOfYear should return 60 for Feb 29 in the pre-epoch leap year 1952", + ), +] + +DAYOFYEAR_CALENDAR_TESTS: list[ExpressionTestCase] = ( + DAYOFYEAR_EXTRACTION_TESTS + + DAYOFYEAR_BOUNDARY_TESTS + + DAYOFYEAR_YEAR_TESTS + + DAYOFYEAR_PRE_EPOCH_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DAYOFYEAR_CALENDAR_TESTS)) +def test_dayOfYear_calendar(collection, test_case: ExpressionTestCase): + """Test $dayOfYear day-of-year extraction across the calendar and year range.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfYear/test_dayOfYear_date_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfYear/test_dayOfYear_date_types.py new file mode 100644 index 000000000..3e71c27ee --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfYear/test_dayOfYear_date_types.py @@ -0,0 +1,187 @@ +"""Tests for $dayOfYear with Timestamp, ObjectId, and extended-range date inputs.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.date_utils import ( + oid_from_args, + ts_from_args, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DATE_MS_BEFORE_EPOCH, + DATE_MS_EPOCH, + DATE_MS_MAX, + DATE_MS_MIN, + DATE_MS_YEAR_10000, + OID_MAX_SIGNED32, + OID_MAX_UNSIGNED32, + OID_MIN_SIGNED32, + TS_MAX_SIGNED32, + TS_MAX_UNSIGNED32, +) + +# Property [Timestamp Input]: a BSON Timestamp is accepted as a date and yields its ordinal day. +DAYOFYEAR_TIMESTAMP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "timestamp_day_1", + doc={"date": ts_from_args(2024, 1, 1, 0, 0, 0)}, + expression={"$dayOfYear": "$date"}, + expected=1, + msg="$dayOfYear should return 1 for a Timestamp on day 1", + ), + ExpressionTestCase( + "timestamp_day_167", + doc={"date": ts_from_args(2024, 6, 15, 0, 0, 0)}, + expression={"$dayOfYear": "$date"}, + expected=167, + msg="$dayOfYear should return the ordinal day for a Timestamp mid-year", + ), + ExpressionTestCase( + "timestamp_day_366", + doc={"date": ts_from_args(2024, 12, 31, 0, 0, 0)}, + expression={"$dayOfYear": "$date"}, + expected=366, + msg="$dayOfYear should return 366 for a Timestamp on day 366", + ), + ExpressionTestCase( + "timestamp_zero_increment", + doc={"date": ts_from_args(2024, 6, 15, 0, 0, 0, inc=0)}, + expression={"$dayOfYear": "$date"}, + expected=167, + msg="$dayOfYear should return the ordinal day for a Timestamp with a zero increment", + ), + ExpressionTestCase( + "timestamp_epoch", + doc={"date": ts_from_args(1970, 1, 1, 0, 0, 0)}, + expression={"$dayOfYear": "$date"}, + expected=1, + msg="$dayOfYear should return 1 for a Timestamp at the epoch", + ), +] + +# Property [ObjectId Input]: an ObjectId is accepted as a date via its embedded timestamp. +DAYOFYEAR_OBJECTID_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "objectid_day_1", + doc={"date": oid_from_args(2024, 1, 1, 0, 0, 0)}, + expression={"$dayOfYear": "$date"}, + expected=1, + msg="$dayOfYear should return 1 for an ObjectId on day 1", + ), + ExpressionTestCase( + "objectid_day_167", + doc={"date": oid_from_args(2024, 6, 15, 0, 0, 0)}, + expression={"$dayOfYear": "$date"}, + expected=167, + msg="$dayOfYear should return the ordinal day for an ObjectId mid-year", + ), + ExpressionTestCase( + "objectid_day_366", + doc={"date": oid_from_args(2024, 12, 31, 0, 0, 0)}, + expression={"$dayOfYear": "$date"}, + expected=366, + msg="$dayOfYear should return 366 for an ObjectId on day 366", + ), + ExpressionTestCase( + "objectid_epoch", + doc={"date": oid_from_args(1970, 1, 1, 0, 0, 0)}, + expression={"$dayOfYear": "$date"}, + expected=1, + msg="$dayOfYear should return 1 for an ObjectId at the epoch", + ), +] + +# Property [Extended Range]: DatetimeMS, Timestamp, and ObjectId boundary instants +# beyond the native datetime range resolve to the correct ordinal day. +DAYOFYEAR_EXTENDED_RANGE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "date_ms_epoch", + doc={"date": DATE_MS_EPOCH}, + expression={"$dayOfYear": "$date"}, + expected=1, + msg="$dayOfYear should return 1 for the epoch as a DatetimeMS", + ), + ExpressionTestCase( + "date_ms_before_epoch", + doc={"date": DATE_MS_BEFORE_EPOCH}, + expression={"$dayOfYear": "$date"}, + expected=365, + msg="$dayOfYear should return 365 for a DatetimeMS one millisecond before the epoch", + ), + ExpressionTestCase( + "date_ms_year_10000", + doc={"date": DATE_MS_YEAR_10000}, + expression={"$dayOfYear": "$date"}, + expected=1, + msg="$dayOfYear should return the ordinal day for a DatetimeMS at the year-10000 boundary", + ), + ExpressionTestCase( + "date_ms_max", + doc={"date": DATE_MS_MAX}, + expression={"$dayOfYear": "$date"}, + expected=229, + msg="$dayOfYear should return the ordinal day for the maximum 64-bit DatetimeMS", + ), + ExpressionTestCase( + "date_ms_min", + doc={"date": DATE_MS_MIN}, + expression={"$dayOfYear": "$date"}, + expected=136, + msg="$dayOfYear should return the ordinal day for the minimum 64-bit DatetimeMS", + ), + ExpressionTestCase( + "ts_boundary_max_s32", + doc={"date": TS_MAX_SIGNED32}, + expression={"$dayOfYear": "$date"}, + expected=19, + msg="$dayOfYear should return 19 for the max signed 32-bit Timestamp", + ), + ExpressionTestCase( + "ts_boundary_max_u32", + doc={"date": TS_MAX_UNSIGNED32}, + expression={"$dayOfYear": "$date"}, + expected=38, + msg="$dayOfYear should return 38 for the max unsigned 32-bit Timestamp", + ), + ExpressionTestCase( + "oid_boundary_max_s32", + doc={"date": OID_MAX_SIGNED32}, + expression={"$dayOfYear": "$date"}, + expected=19, + msg="$dayOfYear should return 19 for the max signed 32-bit ObjectId", + ), + ExpressionTestCase( + "oid_boundary_min_s32", + doc={"date": OID_MIN_SIGNED32}, + expression={"$dayOfYear": "$date"}, + expected=347, + msg="$dayOfYear should return 347 for the min signed 32-bit ObjectId", + ), + ExpressionTestCase( + "oid_boundary_max_u32", + doc={"date": OID_MAX_UNSIGNED32}, + expression={"$dayOfYear": "$date"}, + expected=365, + msg="$dayOfYear should return 365 for the max unsigned 32-bit ObjectId", + ), +] + +DAYOFYEAR_DATE_TYPES_TESTS: list[ExpressionTestCase] = ( + DAYOFYEAR_TIMESTAMP_TESTS + DAYOFYEAR_OBJECTID_TESTS + DAYOFYEAR_EXTENDED_RANGE_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DAYOFYEAR_DATE_TYPES_TESTS)) +def test_dayOfYear_date_types(collection, test_case: ExpressionTestCase): + """Test $dayOfYear with Timestamp, ObjectId, and extended-range date inputs.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfYear/test_dayOfYear_expressions.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfYear/test_dayOfYear_expressions.py new file mode 100644 index 000000000..386966343 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfYear/test_dayOfYear_expressions.py @@ -0,0 +1,160 @@ +"""Tests for $dayOfYear argument forms, field-path resolution, and expression input.""" + +from datetime import datetime, timezone + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + ISO_DATE_INVALID_ARRAY_INPUT_ERROR, + ISO_DATE_MISSING_DATE_ERROR, + ISO_DATE_UNKNOWN_FIELD_ERROR, + TYPE_MISMATCH_DATE_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Literal Input]: an inline literal date computes the correct day of the year. +DAYOFYEAR_LITERAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "literal_date", + expression={"$dayOfYear": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expected=167, + msg="$dayOfYear should return the day of the year for a literal date operand", + ), +] + +# Property [Argument Forms]: the document form requires exactly a date field, and the +# operand-array form accepts only a single element. +DAYOFYEAR_ARGUMENT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "missing_date", + expression={"$dayOfYear": {"timezone": "UTC"}}, + error_code=ISO_DATE_MISSING_DATE_ERROR, + msg="$dayOfYear should error when the document form omits the date field", + ), + ExpressionTestCase( + "extra_field", + expression={ + "$dayOfYear": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "extra": 1, + } + }, + error_code=ISO_DATE_UNKNOWN_FIELD_ERROR, + msg="$dayOfYear should error for an unknown field in the document form", + ), + ExpressionTestCase( + "empty_array", + expression={"$dayOfYear": []}, + error_code=ISO_DATE_INVALID_ARRAY_INPUT_ERROR, + msg="$dayOfYear should error for an empty operand array", + ), + ExpressionTestCase( + "two_element_array", + expression={ + "$dayOfYear": [ + datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + ] + }, + error_code=ISO_DATE_INVALID_ARRAY_INPUT_ERROR, + msg="$dayOfYear should error for a two-element operand array", + ), + ExpressionTestCase( + "single_element_array", + expression={"$dayOfYear": [datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)]}, + expected=167, + msg="$dayOfYear should accept a single-element operand array as the date", + ), + ExpressionTestCase( + "object_expression_input", + doc={"date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dayOfYear": {"a": "$date"}}, + error_code=ISO_DATE_UNKNOWN_FIELD_ERROR, + msg="$dayOfYear should treat an object with an unknown key as an invalid document form", + ), +] + +# Property [Field-Path Resolution]: the operator accepts a resolved field reference; a path +# that resolves to an array (array-index or array-of-objects) feeds the type contract and errors. +DAYOFYEAR_FIELD_PATH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_field_path", + doc={"a": {"b": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}}, + expression={"$dayOfYear": "$a.b"}, + expected=167, + msg="$dayOfYear should accept a date resolved from a nested field path", + ), + ExpressionTestCase( + "array_index_path", + doc={"a": [{"b": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}]}, + expression={"$dayOfYear": "$a.0.b"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfYear should error when an array-index path resolves to an array", + ), + ExpressionTestCase( + "composite_array_path", + doc={ + "a": [ + {"b": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + {"b": datetime(2024, 7, 15, 0, 0, 0, tzinfo=timezone.utc)}, + ] + }, + expression={"$dayOfYear": "$a.b"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfYear should error when a path over an array of objects resolves to an array", + ), + ExpressionTestCase( + "timezone_from_field", + doc={"date": datetime(2024, 7, 1, 3, 0, 0, tzinfo=timezone.utc), "tz": "America/New_York"}, + expression={"$dayOfYear": {"date": "$date", "timezone": "$tz"}}, + expected=182, + msg="$dayOfYear should apply a timezone resolved from a field reference", + ), + ExpressionTestCase( + "missing_tz_field_ref", + doc={"date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dayOfYear": {"date": "$date", "timezone": "$tz"}}, + expected=None, + msg="$dayOfYear should return null when the timezone field reference is missing", + ), + ExpressionTestCase( + "expression_as_input", + expression={"$dayOfYear": {"$dateFromString": {"dateString": "2024-06-15T00:00:00Z"}}}, + expected=167, + msg="$dayOfYear should accept the result of a nested expression as the date", + ), +] + +# Property [Return Type]: $dayOfYear returns a value of BSON type int. +DAYOFYEAR_RETURN_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "return_type", + doc={"date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$type": {"$dayOfYear": "$date"}}, + expected="int", + msg="$dayOfYear should return an int", + ), +] + +DAYOFYEAR_EXPRESSION_TESTS: list[ExpressionTestCase] = ( + DAYOFYEAR_LITERAL_TESTS + + DAYOFYEAR_ARGUMENT_TESTS + + DAYOFYEAR_FIELD_PATH_TESTS + + DAYOFYEAR_RETURN_TYPE_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DAYOFYEAR_EXPRESSION_TESTS)) +def test_dayOfYear_expressions(collection, test_case: ExpressionTestCase): + """Test $dayOfYear argument forms, field-path resolution, expression input, and return type.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfYear/test_dayOfYear_null_and_type_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfYear/test_dayOfYear_null_and_type_errors.py new file mode 100644 index 000000000..e691e9c93 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfYear/test_dayOfYear_null_and_type_errors.py @@ -0,0 +1,211 @@ +"""Tests for $dayOfYear null propagation and non-date type rejection.""" + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, Regex + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import TYPE_MISMATCH_DATE_ERROR +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + MISSING, +) + +# Property [Null Propagation]: a null or missing date resolves to null rather than an error. +DAYOFYEAR_NULL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_date", + doc={"date": None}, + expression={"$dayOfYear": "$date"}, + expected=None, + msg="$dayOfYear should return null for a null date", + ), + ExpressionTestCase( + "missing_date", + expression={"$dayOfYear": MISSING}, + expected=None, + msg="$dayOfYear should return null when the date references a missing field", + ), +] + +# Property [Type Rejection]: any non-date input type is rejected with a type-mismatch error. +DAYOFYEAR_TYPE_REJECTION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "string_date", + doc={"date": "not-a-date"}, + expression={"$dayOfYear": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfYear should reject a string as the date input", + ), + ExpressionTestCase( + "integer_date", + doc={"date": 42}, + expression={"$dayOfYear": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfYear should reject an int as the date input", + ), + ExpressionTestCase( + "int64_date", + doc={"date": Int64(42)}, + expression={"$dayOfYear": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfYear should reject an int64 as the date input", + ), + ExpressionTestCase( + "double_date", + doc={"date": 3.14}, + expression={"$dayOfYear": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfYear should reject a double as the date input", + ), + ExpressionTestCase( + "decimal128_date", + doc={"date": Decimal128("42")}, + expression={"$dayOfYear": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfYear should reject a decimal128 as the date input", + ), + ExpressionTestCase( + "boolean_date", + doc={"date": True}, + expression={"$dayOfYear": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfYear should reject a boolean as the date input", + ), + ExpressionTestCase( + "array_date", + doc={"date": [1, 2, 3]}, + expression={"$dayOfYear": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfYear should reject an array as the date input", + ), + ExpressionTestCase( + "object_date", + doc={"date": {"a": 1}}, + expression={"$dayOfYear": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfYear should reject an object as the date input", + ), + ExpressionTestCase( + "empty_string_date", + doc={"date": ""}, + expression={"$dayOfYear": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfYear should reject an empty string as the date input", + ), + ExpressionTestCase( + "empty_array_date", + doc={"date": []}, + expression={"$dayOfYear": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfYear should reject an empty array as the date input", + ), + ExpressionTestCase( + "empty_object_date", + doc={"date": {}}, + expression={"$dayOfYear": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfYear should reject an empty object as the date input", + ), + ExpressionTestCase( + "float_nan_date", + doc={"date": FLOAT_NAN}, + expression={"$dayOfYear": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfYear should reject a float NaN as the date input", + ), + ExpressionTestCase( + "decimal128_nan_date", + doc={"date": DECIMAL128_NAN}, + expression={"$dayOfYear": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfYear should reject a decimal128 NaN as the date input", + ), + ExpressionTestCase( + "regex_date", + doc={"date": Regex(".*")}, + expression={"$dayOfYear": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfYear should reject a regex as the date input", + ), + ExpressionTestCase( + "minkey_date", + doc={"date": MinKey()}, + expression={"$dayOfYear": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfYear should reject MinKey as the date input", + ), + ExpressionTestCase( + "maxkey_date", + doc={"date": MaxKey()}, + expression={"$dayOfYear": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfYear should reject MaxKey as the date input", + ), + ExpressionTestCase( + "float_inf_date", + doc={"date": FLOAT_INFINITY}, + expression={"$dayOfYear": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfYear should reject a float infinity as the date input", + ), + ExpressionTestCase( + "float_neg_inf_date", + doc={"date": FLOAT_NEGATIVE_INFINITY}, + expression={"$dayOfYear": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfYear should reject a float negative infinity as the date input", + ), + ExpressionTestCase( + "decimal128_inf_date", + doc={"date": DECIMAL128_INFINITY}, + expression={"$dayOfYear": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfYear should reject a decimal128 infinity as the date input", + ), + ExpressionTestCase( + "decimal128_neg_inf_date", + doc={"date": DECIMAL128_NEGATIVE_INFINITY}, + expression={"$dayOfYear": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfYear should reject a decimal128 negative infinity as the date input", + ), + ExpressionTestCase( + "bindata_date", + doc={"date": Binary(b"")}, + expression={"$dayOfYear": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfYear should reject binary data as the date input", + ), + ExpressionTestCase( + "javascript_date", + doc={"date": Code("function{}")}, + expression={"$dayOfYear": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dayOfYear should reject JavaScript code as the date input", + ), +] + +DAYOFYEAR_NULL_AND_TYPE_ERROR_TESTS: list[ExpressionTestCase] = ( + DAYOFYEAR_NULL_TESTS + DAYOFYEAR_TYPE_REJECTION_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DAYOFYEAR_NULL_AND_TYPE_ERROR_TESTS)) +def test_dayOfYear_null_and_type_errors(collection, test_case: ExpressionTestCase): + """Test $dayOfYear null propagation and non-date type rejection.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfYear/test_dayOfYear_timezone_input_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfYear/test_dayOfYear_timezone_input_types.py new file mode 100644 index 000000000..74f6ce109 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfYear/test_dayOfYear_timezone_input_types.py @@ -0,0 +1,224 @@ +"""Tests for $dayOfYear timezone application when the date is a Timestamp or ObjectId.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.date_utils import ( + oid_from_args, + ts_from_args, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Timestamp Input with Zones]: a Timestamp input honours the zone offset. +DAYOFYEAR_TIMESTAMP_ZONE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_olson_ts_utc_no_cross", + expression={"$dayOfYear": {"date": ts_from_args(2024, 7, 15, 12, 0, 0), "timezone": "UTC"}}, + expected=197, + msg="$dayOfYear should return the same ordinal day for a Timestamp in UTC with no crossing", + ), + ExpressionTestCase( + "tz_olson_ts_tokyo_fwd", + expression={ + "$dayOfYear": {"date": ts_from_args(2024, 6, 30, 22, 0, 0), "timezone": "Asia/Tokyo"} + }, + expected=183, + msg="$dayOfYear should cross forward to day 183 for a Timestamp in Asia/Tokyo", + ), + ExpressionTestCase( + "tz_olson_ts_la_bwd", + expression={ + "$dayOfYear": { + "date": ts_from_args(2024, 7, 1, 3, 0, 0), + "timezone": "America/Los_Angeles", + } + }, + expected=182, + msg="$dayOfYear should cross backward to day 182 for a Timestamp in America/Los_Angeles", + ), + ExpressionTestCase( + "tz_olson_ts_kolkata_fwd", + expression={ + "$dayOfYear": {"date": ts_from_args(2024, 3, 31, 20, 0, 0), "timezone": "Asia/Kolkata"} + }, + expected=92, + msg="$dayOfYear should cross forward to day 92 for a Timestamp in Asia/Kolkata", + ), + ExpressionTestCase( + "tz_olson_ts_ny_year_bwd", + expression={ + "$dayOfYear": { + "date": ts_from_args(2024, 1, 1, 3, 0, 0), + "timezone": "America/New_York", + } + }, + expected=365, + msg="$dayOfYear should cross the year boundary backward for a Timestamp in New York", + ), + ExpressionTestCase( + "tz_olson_ts_helsinki_year_fwd", + expression={ + "$dayOfYear": { + "date": ts_from_args(2024, 12, 31, 23, 0, 0), + "timezone": "Europe/Helsinki", + } + }, + expected=1, + msg="$dayOfYear should cross the year boundary forward for a Timestamp in Europe/Helsinki", + ), + ExpressionTestCase( + "tz_offset_ts_plus9_fwd", + expression={ + "$dayOfYear": {"date": ts_from_args(2024, 6, 30, 22, 0, 0), "timezone": "+09:00"} + }, + expected=183, + msg="$dayOfYear should cross forward to day 183 for a Timestamp at a +09:00 offset", + ), + ExpressionTestCase( + "tz_offset_ts_minus8_bwd", + expression={ + "$dayOfYear": {"date": ts_from_args(2024, 7, 1, 3, 0, 0), "timezone": "-08:00"} + }, + expected=182, + msg="$dayOfYear should cross backward to day 182 for a Timestamp at a -08:00 offset", + ), + ExpressionTestCase( + "tz_offset_ts_plus530_fwd", + expression={ + "$dayOfYear": {"date": ts_from_args(2024, 3, 31, 20, 0, 0), "timezone": "+05:30"} + }, + expected=92, + msg="$dayOfYear should cross forward to day 92 for a Timestamp at a +05:30 offset", + ), + ExpressionTestCase( + "tz_offset_ts_plus545_fwd", + expression={ + "$dayOfYear": {"date": ts_from_args(2024, 8, 31, 23, 0, 0), "timezone": "+05:45"} + }, + expected=245, + msg="$dayOfYear should cross forward to day 245 for a Timestamp at a +05:45 offset", + ), + ExpressionTestCase( + "tz_offset_ts_plus3_leap_fwd", + expression={ + "$dayOfYear": {"date": ts_from_args(2024, 2, 29, 23, 30, 0), "timezone": "+03:00"} + }, + expected=61, + msg="$dayOfYear should cross the leap-day boundary forward for a Timestamp at +03:00", + ), +] + +# Property [ObjectId Input with Zones]: an ObjectId input honours the zone offset. +DAYOFYEAR_OBJECTID_ZONE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_olson_oid_utc_no_cross", + expression={ + "$dayOfYear": {"date": oid_from_args(2024, 7, 15, 12, 0, 0), "timezone": "UTC"} + }, + expected=197, + msg="$dayOfYear should return the same ordinal day for an ObjectId in UTC with no crossing", + ), + ExpressionTestCase( + "tz_olson_oid_tokyo_fwd", + expression={ + "$dayOfYear": {"date": oid_from_args(2024, 6, 30, 22, 0, 0), "timezone": "Asia/Tokyo"} + }, + expected=183, + msg="$dayOfYear should cross forward to day 183 for an ObjectId in Asia/Tokyo", + ), + ExpressionTestCase( + "tz_olson_oid_la_bwd", + expression={ + "$dayOfYear": { + "date": oid_from_args(2024, 7, 1, 3, 0, 0), + "timezone": "America/Los_Angeles", + } + }, + expected=182, + msg="$dayOfYear should cross backward to day 182 for an ObjectId in America/Los_Angeles", + ), + ExpressionTestCase( + "tz_olson_oid_kolkata_fwd", + expression={ + "$dayOfYear": { + "date": oid_from_args(2024, 3, 31, 20, 0, 0), + "timezone": "Asia/Kolkata", + } + }, + expected=92, + msg="$dayOfYear should cross forward to day 92 for an ObjectId in Asia/Kolkata", + ), + ExpressionTestCase( + "tz_olson_oid_ny_year_bwd", + expression={ + "$dayOfYear": { + "date": oid_from_args(2024, 1, 1, 3, 0, 0), + "timezone": "America/New_York", + } + }, + expected=365, + msg="$dayOfYear should cross the year boundary backward for an ObjectId in New York", + ), + ExpressionTestCase( + "tz_olson_oid_helsinki_year_fwd", + expression={ + "$dayOfYear": { + "date": oid_from_args(2024, 12, 31, 23, 0, 0), + "timezone": "Europe/Helsinki", + } + }, + expected=1, + msg="$dayOfYear should cross the year boundary forward for an ObjectId in Europe/Helsinki", + ), + ExpressionTestCase( + "tz_offset_oid_plus9_fwd", + expression={ + "$dayOfYear": {"date": oid_from_args(2024, 6, 30, 22, 0, 0), "timezone": "+09:00"} + }, + expected=183, + msg="$dayOfYear should cross forward to day 183 for an ObjectId at a +09:00 offset", + ), + ExpressionTestCase( + "tz_offset_oid_minus8_bwd", + expression={ + "$dayOfYear": {"date": oid_from_args(2024, 7, 1, 3, 0, 0), "timezone": "-08:00"} + }, + expected=182, + msg="$dayOfYear should cross backward to day 182 for an ObjectId at a -08:00 offset", + ), + ExpressionTestCase( + "tz_offset_oid_plus530_fwd", + expression={ + "$dayOfYear": {"date": oid_from_args(2024, 3, 31, 20, 0, 0), "timezone": "+05:30"} + }, + expected=92, + msg="$dayOfYear should cross forward to day 92 for an ObjectId at a +05:30 offset", + ), + ExpressionTestCase( + "tz_offset_oid_plus3_leap_fwd", + expression={ + "$dayOfYear": {"date": oid_from_args(2024, 2, 29, 23, 30, 0), "timezone": "+03:00"} + }, + expected=61, + msg="$dayOfYear should cross the leap-day boundary forward for an ObjectId at +03:00", + ), +] + +DAYOFYEAR_TIMEZONE_INPUT_TYPES_TESTS: list[ExpressionTestCase] = ( + DAYOFYEAR_TIMESTAMP_ZONE_TESTS + DAYOFYEAR_OBJECTID_ZONE_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DAYOFYEAR_TIMEZONE_INPUT_TYPES_TESTS)) +def test_dayOfYear_timezone_input_types(collection, test_case: ExpressionTestCase): + """Test $dayOfYear timezone application for Timestamp and ObjectId date inputs.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfYear/test_dayOfYear_timezone_names.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfYear/test_dayOfYear_timezone_names.py new file mode 100644 index 000000000..08b3de4fd --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfYear/test_dayOfYear_timezone_names.py @@ -0,0 +1,237 @@ +"""Tests for $dayOfYear named-timezone application, including DST and abbreviations.""" + +from datetime import datetime, timezone + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Named Zones]: a named zone or abbreviation shifts the instant before the ordinal +# day is taken, which may cross a day, year, or leap boundary depending on the offset and DST. +DAYOFYEAR_OLSON_DATETIME_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_olson_dt_utc_no_cross", + expression={ + "$dayOfYear": { + "date": datetime(2024, 7, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "UTC", + } + }, + expected=197, + msg="$dayOfYear should not change the ordinal day for UTC with no day crossing", + ), + ExpressionTestCase( + "tz_olson_dt_ny_no_cross", + expression={ + "$dayOfYear": { + "date": datetime(2024, 7, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "America/New_York", + } + }, + expected=197, + msg="$dayOfYear should keep the ordinal day for America/New_York with no day crossing", + ), + ExpressionTestCase( + "tz_olson_dt_tokyo_no_cross", + expression={ + "$dayOfYear": { + "date": datetime(2024, 7, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "Asia/Tokyo", + } + }, + expected=197, + msg="$dayOfYear should not change the ordinal day for Asia/Tokyo with no day crossing", + ), + ExpressionTestCase( + "tz_olson_dt_tokyo_fwd", + expression={ + "$dayOfYear": { + "date": datetime(2024, 6, 30, 22, 0, 0, tzinfo=timezone.utc), + "timezone": "Asia/Tokyo", + } + }, + expected=183, + msg="$dayOfYear should cross forward to day 183 for Asia/Tokyo (+09:00)", + ), + ExpressionTestCase( + "tz_olson_dt_la_bwd", + expression={ + "$dayOfYear": { + "date": datetime(2024, 7, 1, 3, 0, 0, tzinfo=timezone.utc), + "timezone": "America/Los_Angeles", + } + }, + expected=182, + msg="$dayOfYear should cross backward to day 182 for America/Los_Angeles (PDT)", + ), + ExpressionTestCase( + "tz_olson_dt_denver_bwd", + expression={ + "$dayOfYear": { + "date": datetime(2024, 11, 1, 5, 0, 0, tzinfo=timezone.utc), + "timezone": "America/Denver", + } + }, + expected=305, + msg="$dayOfYear should cross backward to day 305 for America/Denver (MDT)", + ), + ExpressionTestCase( + "tz_olson_dt_kolkata_fwd", + expression={ + "$dayOfYear": { + "date": datetime(2024, 3, 31, 20, 0, 0, tzinfo=timezone.utc), + "timezone": "Asia/Kolkata", + } + }, + expected=92, + msg="$dayOfYear should cross forward to day 92 for Asia/Kolkata (+05:30)", + ), + ExpressionTestCase( + "tz_olson_dt_kathmandu_fwd", + expression={ + "$dayOfYear": { + "date": datetime(2024, 8, 31, 23, 0, 0, tzinfo=timezone.utc), + "timezone": "Asia/Kathmandu", + } + }, + expected=245, + msg="$dayOfYear should cross forward to day 245 for Asia/Kathmandu (+05:45)", + ), + ExpressionTestCase( + "tz_olson_dt_ny_year_bwd", + expression={ + "$dayOfYear": { + "date": datetime(2024, 1, 1, 3, 0, 0, tzinfo=timezone.utc), + "timezone": "America/New_York", + } + }, + expected=365, + msg="$dayOfYear should cross the year boundary backward to day 365 for America/New_York", + ), + ExpressionTestCase( + "tz_olson_dt_ny_year_stays", + expression={ + "$dayOfYear": { + "date": datetime(2024, 1, 1, 6, 0, 0, tzinfo=timezone.utc), + "timezone": "America/New_York", + } + }, + expected=1, + msg="$dayOfYear should stay on day 1 for America/New_York after the year boundary", + ), + ExpressionTestCase( + "tz_olson_dt_helsinki_year_fwd", + expression={ + "$dayOfYear": { + "date": datetime(2024, 12, 31, 23, 0, 0, tzinfo=timezone.utc), + "timezone": "Europe/Helsinki", + } + }, + expected=1, + msg="$dayOfYear should cross the year boundary forward to day 1 for Europe/Helsinki", + ), + ExpressionTestCase( + "tz_olson_dt_moscow_leap_fwd", + expression={ + "$dayOfYear": { + "date": datetime(2024, 2, 29, 23, 30, 0, tzinfo=timezone.utc), + "timezone": "Europe/Moscow", + } + }, + expected=61, + msg="$dayOfYear should cross the leap-day boundary forward to day 61 for Europe/Moscow", + ), + ExpressionTestCase( + "tz_olson_dt_ny_leap_bwd", + expression={ + "$dayOfYear": { + "date": datetime(2024, 3, 1, 2, 0, 0, tzinfo=timezone.utc), + "timezone": "America/New_York", + } + }, + expected=60, + msg="$dayOfYear should cross backward to leap day 60 for America/New_York", + ), + ExpressionTestCase( + "tz_olson_dt_ny_dst_spring", + expression={ + "$dayOfYear": { + "date": datetime(2024, 3, 10, 7, 0, 0, tzinfo=timezone.utc), + "timezone": "America/New_York", + } + }, + expected=70, + msg="$dayOfYear should return 70 across the spring DST transition in America/New_York", + ), + ExpressionTestCase( + "tz_olson_dt_ny_dst_fall", + expression={ + "$dayOfYear": { + "date": datetime(2024, 11, 3, 6, 0, 0, tzinfo=timezone.utc), + "timezone": "America/New_York", + } + }, + expected=308, + msg="$dayOfYear should return 308 across the fall DST transition in America/New_York", + ), + ExpressionTestCase( + "tz_olson_dt_london_bst_start", + expression={ + "$dayOfYear": { + "date": datetime(2024, 3, 31, 0, 30, 0, tzinfo=timezone.utc), + "timezone": "Europe/London", + } + }, + expected=91, + msg="$dayOfYear should return 91 at the Europe/London BST start", + ), + ExpressionTestCase( + "tz_olson_dt_london_bst", + expression={ + "$dayOfYear": { + "date": datetime(2020, 6, 30, 23, 30, 0, tzinfo=timezone.utc), + "timezone": "Europe/London", + } + }, + expected=183, + msg="$dayOfYear should cross forward to day 183 for Europe/London in summer (BST)", + ), + ExpressionTestCase( + "tz_olson_dt_pacific_apia", + expression={ + "$dayOfYear": { + "date": datetime(2024, 6, 30, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "Pacific/Apia", + } + }, + expected=183, + msg="$dayOfYear should cross forward to day 183 for Pacific/Apia (+13:00)", + ), + ExpressionTestCase( + "tz_est_abbreviation", + expression={ + "$dayOfYear": { + "date": datetime(2024, 7, 1, 3, 0, 0, tzinfo=timezone.utc), + "timezone": "EST", + } + }, + expected=182, + msg="$dayOfYear should accept the EST three-letter abbreviation", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(DAYOFYEAR_OLSON_DATETIME_TESTS)) +def test_dayOfYear_timezone_names(collection, test_case: ExpressionTestCase): + """Test $dayOfYear named-timezone application across zones, DST, and abbreviations.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfYear/test_dayOfYear_timezone_offsets.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfYear/test_dayOfYear_timezone_offsets.py new file mode 100644 index 000000000..4903a43aa --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfYear/test_dayOfYear_timezone_offsets.py @@ -0,0 +1,259 @@ +"""Tests for $dayOfYear UTC-offset timezone application, including edge and unusual offsets.""" + +from datetime import datetime, timezone + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [UTC Offsets, datetime]: an explicit +HH:MM/-HH:MM offset shifts the instant, +# including half/quarter-hour, extreme, and out-of-range offsets the server still accepts. +DAYOFYEAR_OFFSET_DATETIME_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_offset_dt_plus0_no_cross", + expression={ + "$dayOfYear": { + "date": datetime(2024, 7, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "+00:00", + } + }, + expected=197, + msg="$dayOfYear should not change the ordinal day for a +00:00 offset with no crossing", + ), + ExpressionTestCase( + "tz_offset_dt_minus5_no_cross", + expression={ + "$dayOfYear": { + "date": datetime(2024, 7, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "-05:00", + } + }, + expected=197, + msg="$dayOfYear should not change the ordinal day for a -05:00 offset with no crossing", + ), + ExpressionTestCase( + "tz_offset_dt_plus9_fwd", + expression={ + "$dayOfYear": { + "date": datetime(2024, 6, 30, 22, 0, 0, tzinfo=timezone.utc), + "timezone": "+09:00", + } + }, + expected=183, + msg="$dayOfYear should cross forward to day 183 for a +09:00 offset", + ), + ExpressionTestCase( + "tz_offset_dt_minus8_bwd", + expression={ + "$dayOfYear": { + "date": datetime(2024, 7, 1, 3, 0, 0, tzinfo=timezone.utc), + "timezone": "-08:00", + } + }, + expected=182, + msg="$dayOfYear should cross backward to day 182 for a -08:00 offset", + ), + ExpressionTestCase( + "tz_offset_dt_plus530_fwd", + expression={ + "$dayOfYear": { + "date": datetime(2024, 3, 31, 20, 0, 0, tzinfo=timezone.utc), + "timezone": "+05:30", + } + }, + expected=92, + msg="$dayOfYear should cross forward to day 92 for a +05:30 half-hour offset", + ), + ExpressionTestCase( + "tz_offset_dt_plus545_fwd", + expression={ + "$dayOfYear": { + "date": datetime(2024, 8, 31, 23, 0, 0, tzinfo=timezone.utc), + "timezone": "+05:45", + } + }, + expected=245, + msg="$dayOfYear should cross forward to day 245 for a +05:45 quarter-hour offset", + ), + ExpressionTestCase( + "tz_offset_dt_minus5_year_bwd", + expression={ + "$dayOfYear": { + "date": datetime(2024, 1, 1, 3, 0, 0, tzinfo=timezone.utc), + "timezone": "-05:00", + } + }, + expected=365, + msg="$dayOfYear should cross the year boundary backward to day 365 for a -05:00 offset", + ), + ExpressionTestCase( + "tz_offset_dt_plus2_year_fwd", + expression={ + "$dayOfYear": { + "date": datetime(2024, 12, 31, 23, 0, 0, tzinfo=timezone.utc), + "timezone": "+02:00", + } + }, + expected=1, + msg="$dayOfYear should cross the year boundary forward to day 1 for a +02:00 offset", + ), + ExpressionTestCase( + "tz_offset_dt_plus3_leap_fwd", + expression={ + "$dayOfYear": { + "date": datetime(2024, 2, 29, 23, 30, 0, tzinfo=timezone.utc), + "timezone": "+03:00", + } + }, + expected=61, + msg="$dayOfYear should cross the leap-day boundary forward to day 61 for a +03:00 offset", + ), + ExpressionTestCase( + "tz_offset_dt_minus5_leap_bwd", + expression={ + "$dayOfYear": { + "date": datetime(2024, 3, 1, 2, 0, 0, tzinfo=timezone.utc), + "timezone": "-05:00", + } + }, + expected=60, + msg="$dayOfYear should cross backward to leap day 60 for a -05:00 offset", + ), + ExpressionTestCase( + "tz_offset_dt_plus14_fwd", + expression={ + "$dayOfYear": { + "date": datetime(2024, 6, 30, 11, 0, 0, tzinfo=timezone.utc), + "timezone": "+14:00", + } + }, + expected=183, + msg="$dayOfYear should cross forward to day 183 for the extreme +14:00 offset", + ), + ExpressionTestCase( + "tz_offset_dt_minus12_bwd", + expression={ + "$dayOfYear": { + "date": datetime(2024, 7, 1, 11, 0, 0, tzinfo=timezone.utc), + "timezone": "-12:00", + } + }, + expected=182, + msg="$dayOfYear should cross backward to day 182 for the extreme -12:00 offset", + ), + ExpressionTestCase( + "tz_offset_no_colon", + expression={ + "$dayOfYear": { + "date": datetime(2024, 7, 1, 3, 0, 0, tzinfo=timezone.utc), + "timezone": "-0500", + } + }, + expected=182, + msg="$dayOfYear should accept a compact -0500 offset without a colon", + ), + ExpressionTestCase( + "tz_offset_hour_only", + expression={ + "$dayOfYear": { + "date": datetime(2024, 7, 1, 3, 0, 0, tzinfo=timezone.utc), + "timezone": "-08", + } + }, + expected=182, + msg="$dayOfYear should accept an hour-only -08 offset", + ), + ExpressionTestCase( + "tz_offset_minus13", + expression={ + "$dayOfYear": { + "date": datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "-13:00", + } + }, + expected=365, + msg="$dayOfYear should accept a -13:00 offset", + ), + ExpressionTestCase( + "tz_offset_plus15", + expression={ + "$dayOfYear": { + "date": datetime(2024, 6, 30, 10, 0, 0, tzinfo=timezone.utc), + "timezone": "+15:00", + } + }, + expected=183, + msg="$dayOfYear should accept a +15:00 offset", + ), + ExpressionTestCase( + "tz_offset_minus0330", + expression={ + "$dayOfYear": { + "date": datetime(2024, 7, 1, 2, 0, 0, tzinfo=timezone.utc), + "timezone": "-03:30", + } + }, + expected=182, + msg="$dayOfYear should accept a -03:30 half-hour west offset", + ), + ExpressionTestCase( + "tz_offset_plus0570", + expression={ + "$dayOfYear": { + "date": datetime(2024, 6, 30, 17, 0, 0, tzinfo=timezone.utc), + "timezone": "+05:70", + } + }, + expected=182, + msg="$dayOfYear should accept a +05:70 (70-minute) offset", + ), + ExpressionTestCase( + "tz_offset_minus0570", + expression={ + "$dayOfYear": { + "date": datetime(2024, 7, 1, 5, 0, 0, tzinfo=timezone.utc), + "timezone": "-05:70", + } + }, + expected=182, + msg="$dayOfYear should accept a -05:70 (70-minute) offset", + ), + ExpressionTestCase( + "tz_offset_plus2500", + expression={ + "$dayOfYear": { + "date": datetime(2024, 6, 29, 0, 0, 0, tzinfo=timezone.utc), + "timezone": "+25:00", + } + }, + expected=182, + msg="$dayOfYear should accept a +25:00 (25-hour) offset", + ), + ExpressionTestCase( + "tz_offset_minus2500", + expression={ + "$dayOfYear": { + "date": datetime(2024, 7, 2, 0, 0, 0, tzinfo=timezone.utc), + "timezone": "-25:00", + } + }, + expected=182, + msg="$dayOfYear should accept a -25:00 (25-hour) offset", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(DAYOFYEAR_OFFSET_DATETIME_TESTS)) +def test_dayOfYear_timezone_offsets(collection, test_case: ExpressionTestCase): + """Test $dayOfYear UTC-offset timezone application, including edge and unusual offsets.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfYear/test_dayOfYear_timezone_validation.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfYear/test_dayOfYear_timezone_validation.py new file mode 100644 index 000000000..9f56e9da2 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dayOfYear/test_dayOfYear_timezone_validation.py @@ -0,0 +1,151 @@ +"""Tests for $dayOfYear timezone validation and null-timezone propagation.""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + INVALID_TIMEZONE_ERROR, + INVALID_TIMEZONE_TYPE_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Timezone Validation]: unparseable zone strings, wrong-typed timezones, and +# null timezones are rejected or propagate null. +DAYOFYEAR_TIMEZONE_VALIDATION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "invalid_tz_olson", + expression={ + "$dayOfYear": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "Not/A_Timezone", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$dayOfYear should reject an unparseable Olson-like timezone string", + ), + ExpressionTestCase( + "invalid_tz_nonexistent_olson", + expression={ + "$dayOfYear": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "America/Nowhere", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$dayOfYear should reject a non-existent Olson timezone", + ), + ExpressionTestCase( + "invalid_tz_offset_format", + expression={ + "$dayOfYear": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "25:00", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$dayOfYear should reject a bare 25:00 offset without a sign", + ), + ExpressionTestCase( + "invalid_tz_numeric", + expression={ + "$dayOfYear": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "123", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$dayOfYear should reject a numeric-string timezone", + ), + ExpressionTestCase( + "invalid_tz_empty_string", + expression={ + "$dayOfYear": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$dayOfYear should reject an empty-string timezone", + ), + ExpressionTestCase( + "invalid_tz_olson_lowercase", + expression={ + "$dayOfYear": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "america/new_york", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$dayOfYear should reject an all-lowercase Olson name", + ), + ExpressionTestCase( + "invalid_tz_olson_uppercase", + expression={ + "$dayOfYear": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "AMERICA/NEW_YORK", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$dayOfYear should reject an all-uppercase Olson name", + ), + *[ + ExpressionTestCase( + f"invalid_tz_{tid}", + expression={ + "$dayOfYear": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": val, + } + }, + error_code=INVALID_TIMEZONE_TYPE_ERROR, + msg=f"$dayOfYear should reject a {tid} timezone", + ) + for tid, val in [ + ("int", 5), + ("int64", Int64(5)), + ("double", 3.14), + ("decimal128", Decimal128("5")), + ("bool", True), + ("object", {"tz": "UTC"}), + ("array", ["UTC"]), + ("datetime", datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] + ], + ExpressionTestCase( + "null_tz", + expression={ + "$dayOfYear": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": None, + } + }, + expected=None, + msg="$dayOfYear should return null for a null timezone", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(DAYOFYEAR_TIMEZONE_VALIDATION_TESTS)) +def test_dayOfYear_timezone_validation(collection, test_case: ExpressionTestCase): + """Test $dayOfYear timezone validation and null-timezone propagation.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) From 2f13ccec5356b6c080ef8a79b0e7696647bfbced Mon Sep 17 00:00:00 2001 From: "Alina (Xi) Li" <96995091+alinaliBQ@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:08:41 -0700 Subject: [PATCH 30/35] Add $reduce, $slice, and $size tests (#682) Signed-off-by: Alina (Xi) Li Co-authored-by: Alina (Xi) Li Co-authored-by: Leszek Kurzyna Signed-off-by: RyanGarfinkel <113050972+RyanGarfinkel@users.noreply.github.com> --- .../expressions/array/reduce/__init__.py | 0 .../test_expression_reduce_bson_types.py | 380 +++++++++++++ .../test_expression_reduce_core_behavior.py | 536 ++++++++++++++++++ .../reduce/test_expression_reduce_errors.py | 300 ++++++++++ .../test_expression_reduce_expressions.py | 252 ++++++++ .../reduce/test_smoke_expression_reduce.py | 2 +- .../expressions/array/size/__init__.py | 0 .../size/test_expression_size_bson_types.py | 267 +++++++++ .../test_expression_size_core_behavior.py | 200 +++++++ .../array/size/test_expression_size_errors.py | 222 ++++++++ .../size/test_expression_size_expressions.py | 67 +++ .../array/size/test_smoke_expression_size.py | 2 +- .../expressions/array/slice/__init__.py | 0 .../test_expression_slice_core_behavior.py | 439 ++++++++++++++ .../test_expression_slice_element_types.py | 313 ++++++++++ .../slice/test_expression_slice_errors.py | 433 ++++++++++++++ .../test_expression_slice_expressions.py | 76 +++ .../test_expression_slice_null_missing.py | 133 +++++ .../test_expression_slice_position_errors.py | 189 ++++++ .../slice/test_smoke_expression_slice.py | 2 +- .../test_expressions_combination_reduce.py | 107 ++++ .../test_expressions_combination_size.py | 68 +++ .../test_expressions_combination_slice.py | 75 +++ 23 files changed, 4060 insertions(+), 3 deletions(-) create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/reduce/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/reduce/test_expression_reduce_bson_types.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/reduce/test_expression_reduce_core_behavior.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/reduce/test_expression_reduce_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/reduce/test_expression_reduce_expressions.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/size/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/size/test_expression_size_bson_types.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/size/test_expression_size_core_behavior.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/size/test_expression_size_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/size/test_expression_size_expressions.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/slice/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/slice/test_expression_slice_core_behavior.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/slice/test_expression_slice_element_types.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/slice/test_expression_slice_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/slice/test_expression_slice_expressions.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/slice/test_expression_slice_null_missing.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/slice/test_expression_slice_position_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_reduce.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_size.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_slice.py diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/reduce/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/reduce/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/reduce/test_expression_reduce_bson_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/reduce/test_expression_reduce_bson_types.py new file mode 100644 index 000000000..fbd79cfc3 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/reduce/test_expression_reduce_bson_types.py @@ -0,0 +1,380 @@ +""" +BSON type element tests for $reduce expression. + +Most cases use identity reduction (concatArrays into an accumulator array) to verify +BSON element-type preservation, including special numeric and boundary values; the +typed-sum cases instead verify that $add preserves the correct output BSON type. +""" + +from datetime import datetime, timezone +from uuid import UUID + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ONE_AND_HALF, + DECIMAL128_TRAILING_ZERO, + DECIMAL128_TWO_AND_HALF, + DECIMAL128_ZERO, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + INT32_MAX, + INT32_MIN, + INT64_MAX, + INT64_MIN, + INT64_ZERO, +) + +# Property [Type Preservation]: $reduce preserves each element's BSON type via identity reduction. +BSON_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int64_values", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": [], + "in": {"$concatArrays": ["$$value", ["$$this"]]}, + } + }, + doc={"arr": [Int64(1), Int64(2), Int64(3)]}, + expected=[Int64(1), Int64(2), Int64(3)], + msg="$reduce should preserve Int64 element values", + ), + ExpressionTestCase( + "decimal128_values", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": [], + "in": {"$concatArrays": ["$$value", ["$$this"]]}, + } + }, + doc={"arr": [DECIMAL128_ONE_AND_HALF, DECIMAL128_TWO_AND_HALF]}, + expected=[DECIMAL128_ONE_AND_HALF, DECIMAL128_TWO_AND_HALF], + msg="$reduce should preserve Decimal128 element values", + ), + ExpressionTestCase( + "datetime_values", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": [], + "in": {"$concatArrays": ["$$value", ["$$this"]]}, + } + }, + doc={ + "arr": [ + datetime(2024, 1, 1, tzinfo=timezone.utc), + datetime(2024, 6, 1, tzinfo=timezone.utc), + ] + }, + expected=[ + datetime(2024, 1, 1, tzinfo=timezone.utc), + datetime(2024, 6, 1, tzinfo=timezone.utc), + ], + msg="$reduce should preserve datetime element values", + ), + ExpressionTestCase( + "objectid_values", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": [], + "in": {"$concatArrays": ["$$value", ["$$this"]]}, + } + }, + doc={"arr": [ObjectId("000000000000000000000001"), ObjectId("000000000000000000000002")]}, + expected=[ObjectId("000000000000000000000001"), ObjectId("000000000000000000000002")], + msg="$reduce should preserve ObjectId element values", + ), + ExpressionTestCase( + "binary_values", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": [], + "in": {"$concatArrays": ["$$value", ["$$this"]]}, + } + }, + doc={"arr": [Binary(b"\x01", 0), Binary(b"\x02", 0)]}, + expected=[b"\x01", b"\x02"], + msg="$reduce should preserve Binary element values", + ), + ExpressionTestCase( + "binary_subtype_preservation", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": [], + "in": {"$concatArrays": ["$$value", ["$$this"]]}, + } + }, + doc={"arr": [Binary(b"\x01", 128), Binary(b"\x02", 128)]}, + expected=[Binary(b"\x01", 128), Binary(b"\x02", 128)], + msg="$reduce should preserve the Binary subtype", + ), + ExpressionTestCase( + "regex_values", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": [], + "in": {"$concatArrays": ["$$value", ["$$this"]]}, + } + }, + doc={"arr": [Regex("^a", "i"), Regex("^b", "i")]}, + expected=[Regex("^a", "i"), Regex("^b", "i")], + msg="$reduce should preserve Regex element values", + ), + ExpressionTestCase( + "javascript_values", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": [], + "in": {"$concatArrays": ["$$value", ["$$this"]]}, + } + }, + doc={"arr": [Code("a"), Code("b")]}, + expected=[Code("a"), Code("b")], + msg="$reduce should preserve JavaScript code element values", + ), + ExpressionTestCase( + "timestamp_values", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": [], + "in": {"$concatArrays": ["$$value", ["$$this"]]}, + } + }, + doc={"arr": [Timestamp(1, 0), Timestamp(2, 0)]}, + expected=[Timestamp(1, 0), Timestamp(2, 0)], + msg="$reduce should preserve Timestamp element values", + ), + ExpressionTestCase( + "minkey_maxkey", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": [], + "in": {"$concatArrays": ["$$value", ["$$this"]]}, + } + }, + doc={"arr": [MinKey(), MaxKey()]}, + expected=[MinKey(), MaxKey()], + msg="$reduce should preserve MinKey and MaxKey element values", + ), + ExpressionTestCase( + "uuid_values", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": [], + "in": {"$concatArrays": ["$$value", ["$$this"]]}, + } + }, + doc={ + "arr": [ + Binary.from_uuid(UUID("01234567-89ab-cdef-fedc-ba9876543210")), + Binary.from_uuid(UUID("fedcba98-7654-3210-0123-456789abcdef")), + ] + }, + expected=[ + Binary.from_uuid(UUID("01234567-89ab-cdef-fedc-ba9876543210")), + Binary.from_uuid(UUID("fedcba98-7654-3210-0123-456789abcdef")), + ], + msg="$reduce should preserve UUID Binary element values", + ), +] + +# Property [Mixed Types]: $reduce preserves elements of mixed BSON types. +MIXED_BSON_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "mixed_bson_types", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": [], + "in": {"$concatArrays": ["$$value", ["$$this"]]}, + } + }, + doc={"arr": [1, "two", Int64(3), Decimal128("4"), True, None, MinKey()]}, + expected=[1, "two", Int64(3), Decimal128("4"), True, None, MinKey()], + msg="$reduce should preserve mixed BSON types", + ), +] + +# Property [Special Numeric Elements]: $reduce preserves Infinity, NaN, INT32/INT64 +# boundary values, and negative-zero elements. +SPECIAL_NUMERIC_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "float_nan_values", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": [], + "in": {"$concatArrays": ["$$value", ["$$this"]]}, + } + }, + doc={"arr": [FLOAT_NAN, 1]}, + expected=[pytest.approx(FLOAT_NAN, nan_ok=True), 1], + msg="$reduce should preserve float NaN values", + ), + ExpressionTestCase( + "infinity_values", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": [], + "in": {"$concatArrays": ["$$value", ["$$this"]]}, + } + }, + doc={"arr": [FLOAT_INFINITY, FLOAT_NEGATIVE_INFINITY]}, + expected=[FLOAT_INFINITY, FLOAT_NEGATIVE_INFINITY], + msg="$reduce should preserve infinity values", + ), + ExpressionTestCase( + "decimal128_infinity", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": [], + "in": {"$concatArrays": ["$$value", ["$$this"]]}, + } + }, + doc={"arr": [DECIMAL128_INFINITY, DECIMAL128_NEGATIVE_INFINITY]}, + expected=[DECIMAL128_INFINITY, DECIMAL128_NEGATIVE_INFINITY], + msg="$reduce should preserve Decimal128 infinity values", + ), + ExpressionTestCase( + "boundary_values", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": [], + "in": {"$concatArrays": ["$$value", ["$$this"]]}, + } + }, + doc={"arr": [INT32_MIN, INT32_MAX, INT64_MIN, INT64_MAX]}, + expected=[INT32_MIN, INT32_MAX, INT64_MIN, INT64_MAX], + msg="$reduce should preserve numeric boundary values", + ), + ExpressionTestCase( + "negative_zero", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": [], + "in": {"$concatArrays": ["$$value", ["$$this"]]}, + } + }, + doc={"arr": [DOUBLE_NEGATIVE_ZERO, DECIMAL128_NEGATIVE_ZERO]}, + expected=[DOUBLE_NEGATIVE_ZERO, DECIMAL128_NEGATIVE_ZERO], + msg="$reduce should preserve negative zero values", + ), +] + +# Property [Decimal128 Precision]: $reduce preserves Decimal128 precision and special values. +DECIMAL128_PRECISION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "decimal128_trailing_zeros", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": [], + "in": {"$concatArrays": ["$$value", ["$$this"]]}, + } + }, + doc={"arr": [DECIMAL128_TRAILING_ZERO, Decimal128("1.00"), Decimal128("1.000")]}, + expected=[DECIMAL128_TRAILING_ZERO, Decimal128("1.00"), Decimal128("1.000")], + msg="$reduce should preserve Decimal128 trailing zeros", + ), + ExpressionTestCase( + "decimal128_nan", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": [], + "in": {"$concatArrays": ["$$value", ["$$this"]]}, + } + }, + doc={"arr": [DECIMAL128_NAN, Decimal128("1")]}, + expected=[DECIMAL128_NAN, Decimal128("1")], + msg="$reduce should preserve Decimal128 NaN", + ), +] + +# Property [Typed Sum]: $reduce sums numeric elements preserving the result BSON type. +BSON_SUM_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "sum_int64", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": INT64_ZERO, + "in": {"$add": ["$$value", "$$this"]}, + } + }, + doc={"arr": [Int64(10), Int64(20), Int64(30)]}, + expected=Int64(60), + msg="$reduce should sum Int64 values", + ), + ExpressionTestCase( + "sum_decimal128", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": DECIMAL128_ZERO, + "in": {"$add": ["$$value", "$$this"]}, + } + }, + doc={"arr": [DECIMAL128_ONE_AND_HALF, DECIMAL128_TWO_AND_HALF, Decimal128("3.0")]}, + expected=Decimal128("7.0"), + msg="$reduce should sum Decimal128 values preserving precision", + ), + ExpressionTestCase( + "sum_double", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": DOUBLE_ZERO, + "in": {"$add": ["$$value", "$$this"]}, + } + }, + doc={"arr": [1.5, 2.5, 3.0]}, + expected=7.0, + msg="$reduce should sum double values", + ), +] + +ALL_BSON_TESTS = ( + BSON_TYPE_TESTS + + MIXED_BSON_TESTS + + SPECIAL_NUMERIC_TESTS + + DECIMAL128_PRECISION_TESTS + + BSON_SUM_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_BSON_TESTS)) +def test_reduce_bson_insert(collection, test): + """Test $reduce BSON types with values from inserted documents.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result(result, expected=test.expected, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/reduce/test_expression_reduce_core_behavior.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/reduce/test_expression_reduce_core_behavior.py new file mode 100644 index 000000000..db8ae0537 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/reduce/test_expression_reduce_core_behavior.py @@ -0,0 +1,536 @@ +""" +Core behavior tests for $reduce expression. + +Tests basic reduction (sum, product, concat), empty arrays, null propagation, +various initialValue types, nested arrays, objects as elements, and large arrays. +""" + +import pytest +from bson import Decimal128 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Basic Reduction]: $reduce folds the array left to right applying 'in'. +BASIC_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "basic_sum", + expression={ + "$reduce": {"input": "$arr", "initialValue": 0, "in": {"$add": ["$$value", "$$this"]}} + }, + doc={"arr": [1, 2, 3, 4, 5]}, + expected=15, + msg="$reduce should sum all elements", + ), + ExpressionTestCase( + "basic_product", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": 1, + "in": {"$multiply": ["$$value", "$$this"]}, + } + }, + doc={"arr": [1, 2, 3, 4]}, + expected=24, + msg="$reduce should multiply all elements", + ), + ExpressionTestCase( + "basic_string_concat", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": "", + "in": {"$concat": ["$$value", "$$this"]}, + } + }, + doc={"arr": ["a", "b", "c"]}, + expected="abc", + msg="$reduce should concatenate strings", + ), + ExpressionTestCase( + "basic_count_elements", + expression={ + "$reduce": {"input": "$arr", "initialValue": 0, "in": {"$add": ["$$value", 1]}} + }, + doc={"arr": ["a", "b", "c", "d"]}, + expected=4, + msg="$reduce should count elements", + ), +] + +# Property [Empty Input]: an empty input array returns initialValue unchanged. +EMPTY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "empty_array_int_init", + expression={ + "$reduce": {"input": "$arr", "initialValue": 0, "in": {"$add": ["$$value", "$$this"]}} + }, + doc={"arr": []}, + expected=0, + msg="$reduce should return initialValue for an empty array", + ), + ExpressionTestCase( + "empty_array_string_init", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": "start", + "in": {"$concat": ["$$value", "$$this"]}, + } + }, + doc={"arr": []}, + expected="start", + msg="$reduce should return the string initialValue for an empty array", + ), + ExpressionTestCase( + "empty_array_array_init", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": [], + "in": {"$concatArrays": ["$$value", ["$$this"]]}, + } + }, + doc={"arr": []}, + expected=[], + msg="$reduce should return the array initialValue for an empty array", + ), + ExpressionTestCase( + "empty_array_object_init", + expression={"$reduce": {"input": "$arr", "initialValue": {"count": 0}, "in": "$$value"}}, + doc={"arr": []}, + expected={"count": 0}, + msg="$reduce should return the object initialValue for an empty array", + ), + ExpressionTestCase( + "empty_array_null_init", + expression={"$reduce": {"input": "$arr", "initialValue": None, "in": "$$value"}}, + doc={"arr": []}, + expected=None, + msg="$reduce should return the null initialValue for an empty array", + ), + ExpressionTestCase( + "empty_array_bool_init", + expression={"$reduce": {"input": "$arr", "initialValue": True, "in": "$$value"}}, + doc={"arr": []}, + expected=True, + msg="$reduce should return the true initialValue for an empty array", + ), + ExpressionTestCase( + "empty_array_bool_false_init", + expression={"$reduce": {"input": "$arr", "initialValue": False, "in": "$$value"}}, + doc={"arr": []}, + expected=False, + msg="$reduce should return the false initialValue for an empty array", + ), +] + +# Property [Null Input]: a null input array returns null. +NULL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_input", + expression={ + "$reduce": {"input": "$arr", "initialValue": 0, "in": {"$add": ["$$value", "$$this"]}} + }, + doc={"arr": None}, + expected=None, + msg="$reduce should return null when input is null", + ), +] + +# Property [Single Element]: a one-element array applies 'in' exactly once. +SINGLE_ELEMENT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "single_element_sum", + expression={ + "$reduce": {"input": "$arr", "initialValue": 0, "in": {"$add": ["$$value", "$$this"]}} + }, + doc={"arr": [42]}, + expected=42, + msg="$reduce should reduce a single element", + ), + ExpressionTestCase( + "single_element_identity", + expression={"$reduce": {"input": "$arr", "initialValue": 0, "in": "$$this"}}, + doc={"arr": [99]}, + expected=99, + msg="$reduce should return the element for a single-element identity reduction", + ), +] + +# Property [Array Accumulator]: $concatArrays in 'in' flattens nested arrays. +NESTED_ARRAY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "flatten_one_level", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": [], + "in": {"$concatArrays": ["$$value", "$$this"]}, + } + }, + doc={"arr": [[1, 2], [3, 4], [5]]}, + expected=[1, 2, 3, 4, 5], + msg="$reduce should flatten one level of nesting", + ), + ExpressionTestCase( + "flatten_with_empty", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": [], + "in": {"$concatArrays": ["$$value", "$$this"]}, + } + }, + doc={"arr": [[1], [], [2, 3], []]}, + expected=[1, 2, 3], + msg="$reduce should flatten with empty subarrays", + ), +] + +# Property [Object Elements]: 'in' can read fields of object elements. +OBJECT_ELEMENT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "sum_object_field", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": 0, + "in": {"$add": ["$$value", "$$this.val"]}, + } + }, + doc={"arr": [{"val": 10}, {"val": 20}, {"val": 30}]}, + expected=60, + msg="$reduce should sum object field values", + ), + ExpressionTestCase( + "merge_objects", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": {}, + "in": {"$mergeObjects": ["$$value", "$$this"]}, + } + }, + doc={"arr": [{"a": 1}, {"b": 2}, {"c": 3}]}, + expected={"a": 1, "b": 2, "c": 3}, + msg="$reduce should merge objects", + ), +] + +# Property [Array Building]: 'in' can grow the accumulator array. +ACCUMULATE_ARRAY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "collect_into_array", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": [], + "in": {"$concatArrays": ["$$value", ["$$this"]]}, + } + }, + doc={"arr": [1, 2, 3]}, + expected=[1, 2, 3], + msg="$reduce should collect elements into an array", + ), + ExpressionTestCase( + "collect_doubled", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": [], + "in": {"$concatArrays": ["$$value", [{"$multiply": ["$$this", 2]}]]}, + } + }, + doc={"arr": [1, 2, 3]}, + expected=[2, 4, 6], + msg="$reduce should collect doubled elements", + ), +] + +# Property [Boolean Reduction]: boolean 'in' operators fold to a single boolean. +BOOLEAN_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "all_true", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": True, + "in": {"$and": ["$$value", "$$this"]}, + } + }, + doc={"arr": [True, True, True]}, + expected=True, + msg="$reduce should reduce all-true to true", + ), + ExpressionTestCase( + "and_short_circuits_on_false", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": True, + "in": {"$and": ["$$value", "$$this"]}, + } + }, + doc={"arr": [True, False, True]}, + expected=False, + msg="$reduce should reduce to false when any element is false", + ), + ExpressionTestCase( + "any_true", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": False, + "in": {"$or": ["$$value", "$$this"]}, + } + }, + doc={"arr": [False, False, True]}, + expected=True, + msg="$reduce should reduce to true when any element is true via $or", + ), +] + +# Property [Large Input]: reduction handles large arrays. +LARGE_ARRAY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "large_array_sum", + expression={ + "$reduce": {"input": "$arr", "initialValue": 0, "in": {"$add": ["$$value", "$$this"]}} + }, + doc={"arr": list(range(10_000))}, + expected=sum(range(10_000)), + msg="$reduce should sum a large array", + ), +] + +# Property [Evaluation Order]: elements are processed strictly left to right. +LEFT_TO_RIGHT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "concat_order", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": "", + "in": {"$concat": ["$$value", "$$this"]}, + } + }, + doc={"arr": ["a", "b", "c"]}, + expected="abc", + msg="$reduce should apply 'in' left to right (right-to-left would yield 'cba')", + ), + ExpressionTestCase( + "sequential_subtract", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": 100, + "in": {"$subtract": ["$$value", "$$this"]}, + } + }, + doc={"arr": [10, 5]}, + expected=85, + msg="$reduce should apply $subtract once per element, accumulating the result", + ), + ExpressionTestCase( + "sequential_divide", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": 100, + "in": {"$divide": ["$$value", "$$this"]}, + } + }, + doc={"arr": [2, 5]}, + expected=10.0, + msg="$reduce should apply $divide once per element, accumulating the result", + ), +] + +# Property [Object Accumulator]: $$value can be an object carrying multiple sub-accumulators. +OBJECT_ACCUMULATOR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "sum_and_product", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": {"sum": 5, "product": 2}, + "in": { + "sum": {"$add": ["$$value.sum", "$$this"]}, + "product": {"$multiply": ["$$value.product", "$$this"]}, + }, + } + }, + doc={"arr": [1, 2, 3, 4]}, + expected={"sum": 15, "product": 48}, + msg="$reduce should support an object accumulator", + ), + ExpressionTestCase( + "items_and_sum", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": {"items": [], "sum": 0}, + "in": { + "items": {"$concatArrays": ["$$value.items", ["$$this"]]}, + "sum": {"$add": ["$$value.sum", "$$this"]}, + }, + } + }, + doc={"arr": [1, 2, 3]}, + expected={"items": [1, 2, 3], "sum": 6}, + msg="$reduce should support an object accumulator with an array field", + ), +] + +# Property [In Expression]: 'in' accepts literals and $$value/$$this references. +IN_EXPR_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "literal_in", + expression={"$reduce": {"input": "$arr", "initialValue": 0, "in": 42}}, + doc={"arr": [1, 2, 3]}, + expected=42, + msg="$reduce should return the literal 'in' value each iteration", + ), + ExpressionTestCase( + "value_ref_only", + expression={"$reduce": {"input": "$arr", "initialValue": 0, "in": "$$value"}}, + doc={"arr": [1, 2, 3]}, + expected=0, + msg="$reduce should return initialValue unchanged for a $$value-only 'in'", + ), + ExpressionTestCase( + "this_ref_only", + expression={"$reduce": {"input": "$arr", "initialValue": 0, "in": "$$this"}}, + doc={"arr": [10, 20, 30]}, + expected=30, + msg="$reduce should return the last element for a $$this-only 'in'", + ), + ExpressionTestCase( + "without_value", + expression={"$reduce": {"input": "$arr", "initialValue": 5, "in": {"$add": [5, "$$this"]}}}, + doc={"arr": [1, 2, 3, 4]}, + expected=9, + msg="$reduce should return the last evaluation when 'in' omits $$value", + ), + ExpressionTestCase( + "without_this", + expression={ + "$reduce": {"input": "$arr", "initialValue": 5, "in": {"$add": ["$$value", 5]}} + }, + doc={"arr": [1, 2, 3, 4]}, + expected=25, + msg="$reduce should apply 'in' each iteration when it omits $$this", + ), +] + +# Property [Null Propagation]: a null element or null accumulator propagates through arithmetic. +NULL_PROPAGATION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_propagates_add", + expression={ + "$reduce": {"input": "$arr", "initialValue": 0, "in": {"$add": ["$$value", "$$this"]}} + }, + doc={"arr": [1, None, 3]}, + expected=None, + msg="$reduce should propagate a null element through $add", + ), + ExpressionTestCase( + "null_with_ifNull", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": 0, + "in": {"$add": ["$$value", {"$ifNull": ["$$this", 0]}]}, + } + }, + doc={"arr": [1, None, 3]}, + expected=4, + msg="$reduce should replace null with $ifNull before summing", + ), + ExpressionTestCase( + "null_init_add", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": None, + "in": {"$add": ["$$value", "$$this"]}, + } + }, + doc={"arr": [1, 2]}, + expected=None, + msg="$reduce should return null for a null initialValue with $add", + ), +] + +# Property [Heterogeneous Elements]: mixed element types are handled per the 'in' expression. +HETEROGENEOUS_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "collect_heterogeneous", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": [], + "in": {"$concatArrays": ["$$value", ["$$this"]]}, + } + }, + doc={"arr": [1, "two", True, None, [3], {"four": 4}]}, + expected=[1, "two", True, None, [3], {"four": 4}], + msg="$reduce should collect all element types into an array", + ), + ExpressionTestCase( + "type_bridge_tostring", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": "", + "in": {"$concat": ["$$value", {"$toString": "$$this"}]}, + } + }, + doc={"arr": [1, 2]}, + expected="12", + msg="$reduce should bridge types via $toString", + ), + ExpressionTestCase( + "int_init_decimal_elements", + expression={ + "$reduce": {"input": "$arr", "initialValue": 0, "in": {"$add": ["$$value", "$$this"]}} + }, + doc={"arr": [Decimal128("1"), Decimal128("2")]}, + expected=Decimal128("3"), + msg="$reduce should promote int initialValue with Decimal128 elements", + ), +] + +ALL_TESTS = ( + BASIC_TESTS + + EMPTY_TESTS + + NULL_TESTS + + SINGLE_ELEMENT_TESTS + + NESTED_ARRAY_TESTS + + OBJECT_ELEMENT_TESTS + + ACCUMULATE_ARRAY_TESTS + + BOOLEAN_TESTS + + LARGE_ARRAY_TESTS + + LEFT_TO_RIGHT_TESTS + + OBJECT_ACCUMULATOR_TESTS + + IN_EXPR_TYPE_TESTS + + NULL_PROPAGATION_TESTS + + HETEROGENEOUS_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_reduce_insert(collection, test): + """Test $reduce with values from inserted documents.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/reduce/test_expression_reduce_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/reduce/test_expression_reduce_errors.py new file mode 100644 index 000000000..2232c4c1a --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/reduce/test_expression_reduce_errors.py @@ -0,0 +1,300 @@ +""" +Error tests for $reduce expression. + +Consolidates all $reduce error cases: non-array input (one case per non-deprecated +BSON type), type mismatches between initialValue and elements, non-object argument, +unknown fields, and missing required fields. +Note: $reduce propagates null — null input returns null (tested in core_behavior). +""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + CONCAT_TYPE_ERROR, + REDUCE_INPUT_NOT_ARRAY_ERROR, + REDUCE_MISSING_IN_ERROR, + REDUCE_MISSING_INIT_ERROR, + REDUCE_MISSING_INPUT_ERROR, + REDUCE_NON_OBJECT_ARG_ERROR, + REDUCE_UNKNOWN_FIELD_ERROR, + TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Non-Array Input]: $reduce rejects a non-array input for every non-deprecated BSON type. +NOT_ARRAY_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "string_input", + expression={ + "$reduce": {"input": "$arr", "initialValue": 0, "in": {"$add": ["$$value", "$$this"]}} + }, + doc={"arr": "hello"}, + error_code=REDUCE_INPUT_NOT_ARRAY_ERROR, + msg="$reduce should reject string input", + ), + ExpressionTestCase( + "int_input", + expression={ + "$reduce": {"input": "$arr", "initialValue": 0, "in": {"$add": ["$$value", "$$this"]}} + }, + doc={"arr": 42}, + error_code=REDUCE_INPUT_NOT_ARRAY_ERROR, + msg="$reduce should reject int input", + ), + ExpressionTestCase( + "double_input", + expression={ + "$reduce": {"input": "$arr", "initialValue": 0, "in": {"$add": ["$$value", "$$this"]}} + }, + doc={"arr": 3.14}, + error_code=REDUCE_INPUT_NOT_ARRAY_ERROR, + msg="$reduce should reject double input", + ), + ExpressionTestCase( + "decimal128_input", + expression={ + "$reduce": {"input": "$arr", "initialValue": 0, "in": {"$add": ["$$value", "$$this"]}} + }, + doc={"arr": Decimal128("1")}, + error_code=REDUCE_INPUT_NOT_ARRAY_ERROR, + msg="$reduce should reject decimal128 input", + ), + ExpressionTestCase( + "int64_input", + expression={ + "$reduce": {"input": "$arr", "initialValue": 0, "in": {"$add": ["$$value", "$$this"]}} + }, + doc={"arr": Int64(1)}, + error_code=REDUCE_INPUT_NOT_ARRAY_ERROR, + msg="$reduce should reject int64 input", + ), + ExpressionTestCase( + "bool_input", + expression={ + "$reduce": {"input": "$arr", "initialValue": 0, "in": {"$add": ["$$value", "$$this"]}} + }, + doc={"arr": True}, + error_code=REDUCE_INPUT_NOT_ARRAY_ERROR, + msg="$reduce should reject bool input", + ), + ExpressionTestCase( + "object_input", + expression={ + "$reduce": {"input": "$arr", "initialValue": 0, "in": {"$add": ["$$value", "$$this"]}} + }, + doc={"arr": {"a": 1}}, + error_code=REDUCE_INPUT_NOT_ARRAY_ERROR, + msg="$reduce should reject object input", + ), + ExpressionTestCase( + "objectid_input", + expression={ + "$reduce": {"input": "$arr", "initialValue": 0, "in": {"$add": ["$$value", "$$this"]}} + }, + doc={"arr": ObjectId()}, + error_code=REDUCE_INPUT_NOT_ARRAY_ERROR, + msg="$reduce should reject objectid input", + ), + ExpressionTestCase( + "datetime_input", + expression={ + "$reduce": {"input": "$arr", "initialValue": 0, "in": {"$add": ["$$value", "$$this"]}} + }, + doc={"arr": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + error_code=REDUCE_INPUT_NOT_ARRAY_ERROR, + msg="$reduce should reject datetime input", + ), + ExpressionTestCase( + "binary_input", + expression={ + "$reduce": {"input": "$arr", "initialValue": 0, "in": {"$add": ["$$value", "$$this"]}} + }, + doc={"arr": Binary(b"x", 0)}, + error_code=REDUCE_INPUT_NOT_ARRAY_ERROR, + msg="$reduce should reject binary input", + ), + ExpressionTestCase( + "regex_input", + expression={ + "$reduce": {"input": "$arr", "initialValue": 0, "in": {"$add": ["$$value", "$$this"]}} + }, + doc={"arr": Regex("x")}, + error_code=REDUCE_INPUT_NOT_ARRAY_ERROR, + msg="$reduce should reject regex input", + ), + ExpressionTestCase( + "javascript_input", + expression={ + "$reduce": {"input": "$arr", "initialValue": 0, "in": {"$add": ["$$value", "$$this"]}} + }, + doc={"arr": Code("x")}, + error_code=REDUCE_INPUT_NOT_ARRAY_ERROR, + msg="$reduce should reject JavaScript code input", + ), + ExpressionTestCase( + "timestamp_input", + expression={ + "$reduce": {"input": "$arr", "initialValue": 0, "in": {"$add": ["$$value", "$$this"]}} + }, + doc={"arr": Timestamp(0, 0)}, + error_code=REDUCE_INPUT_NOT_ARRAY_ERROR, + msg="$reduce should reject timestamp input", + ), + ExpressionTestCase( + "minkey_input", + expression={ + "$reduce": {"input": "$arr", "initialValue": 0, "in": {"$add": ["$$value", "$$this"]}} + }, + doc={"arr": MinKey()}, + error_code=REDUCE_INPUT_NOT_ARRAY_ERROR, + msg="$reduce should reject minkey input", + ), + ExpressionTestCase( + "maxkey_input", + expression={ + "$reduce": {"input": "$arr", "initialValue": 0, "in": {"$add": ["$$value", "$$this"]}} + }, + doc={"arr": MaxKey()}, + error_code=REDUCE_INPUT_NOT_ARRAY_ERROR, + msg="$reduce should reject maxkey input", + ), +] + +# Property [Type Mismatch]: an accumulator/element type mismatch propagates the error. +TYPE_MISMATCH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "string_init_int_add", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": "hello", + "in": {"$add": ["$$value", "$$this"]}, + } + }, + doc={"arr": [1]}, + error_code=TYPE_MISMATCH_ERROR, + msg="$reduce should reject a string accumulator passed to $add", + ), + ExpressionTestCase( + "int_init_string_concat", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": 0, + "in": {"$concat": ["$$value", "$$this"]}, + } + }, + doc={"arr": ["a"]}, + error_code=CONCAT_TYPE_ERROR, + msg="$reduce should reject an int accumulator passed to $concat", + ), + ExpressionTestCase( + "mid_iteration_type_mismatch", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": 0, + "in": {"$add": ["$$value", "$$this"]}, + } + }, + doc={"arr": [1, 2, "three", 4]}, + error_code=TYPE_MISMATCH_ERROR, + msg="$reduce should reject a type mismatch mid-iteration", + ), +] + +# Property [Non-Object Argument]: a non-object $reduce argument is rejected. +NON_OBJECT_ARG_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int_arg", + expression={"$reduce": 0}, + error_code=REDUCE_NON_OBJECT_ARG_ERROR, + msg="$reduce should reject an int argument", + ), + ExpressionTestCase( + "string_arg", + expression={"$reduce": "hello"}, + error_code=REDUCE_NON_OBJECT_ARG_ERROR, + msg="$reduce should reject a string argument", + ), + ExpressionTestCase( + "array_arg", + expression={"$reduce": [1, 2, 3]}, + error_code=REDUCE_NON_OBJECT_ARG_ERROR, + msg="$reduce should reject an array argument", + ), + ExpressionTestCase( + "null_arg", + expression={"$reduce": None}, + error_code=REDUCE_NON_OBJECT_ARG_ERROR, + msg="$reduce should reject a null argument", + ), + ExpressionTestCase( + "bool_arg", + expression={"$reduce": True}, + error_code=REDUCE_NON_OBJECT_ARG_ERROR, + msg="$reduce should reject a bool argument", + ), +] + +# Property [Unknown Field]: an unrecognized field in the $reduce argument is rejected. +UNKNOWN_FIELD_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "extra_unknown", + expression={"$reduce": {"input": [1], "initialValue": 0, "in": "$$value", "extra": 1}}, + error_code=REDUCE_UNKNOWN_FIELD_ERROR, + msg="$reduce should reject an unknown field", + ), +] + +# Property [Missing Required Field]: omitting exactly one required field is rejected. +MISSING_REQUIRED_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "missing_input", + expression={"$reduce": {"initialValue": 0, "in": "$$value"}}, + error_code=REDUCE_MISSING_INPUT_ERROR, + msg="$reduce should reject a missing input field", + ), + ExpressionTestCase( + "missing_initialValue", + expression={"$reduce": {"input": [1], "in": "$$value"}}, + error_code=REDUCE_MISSING_INIT_ERROR, + msg="$reduce should reject a missing initialValue field", + ), + ExpressionTestCase( + "missing_in", + expression={"$reduce": {"input": [1], "initialValue": 0}}, + error_code=REDUCE_MISSING_IN_ERROR, + msg="$reduce should reject a missing in field", + ), +] + +INPUT_ERROR_TESTS = NOT_ARRAY_ERROR_TESTS + TYPE_MISMATCH_TESTS +STRUCTURE_ERROR_TESTS = NON_OBJECT_ARG_TESTS + UNKNOWN_FIELD_TESTS + MISSING_REQUIRED_TESTS + + +@pytest.mark.parametrize("test", pytest_params(INPUT_ERROR_TESTS)) +def test_reduce_error_insert(collection, test): + """Test $reduce input errors with values from inserted documents.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +@pytest.mark.parametrize("test", pytest_params(STRUCTURE_ERROR_TESTS)) +def test_reduce_structure_error(collection, test): + """Test $reduce argument structure validation.""" + result = execute_expression(collection, test.expression) + assert_expression_result(result, error_code=test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/reduce/test_expression_reduce_expressions.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/reduce/test_expression_reduce_expressions.py new file mode 100644 index 000000000..e04eed5ae --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/reduce/test_expression_reduce_expressions.py @@ -0,0 +1,252 @@ +""" +Expression and field path tests for $reduce expression. + +Tests field path lookups, composite paths, system variables, +null/missing propagation via expressions, nested $reduce, and +access to outer document fields in the 'in' expression. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Field Path Input]: $reduce resolves a field path to the input array. +FIELD_LOOKUP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_field_path", + expression={ + "$reduce": {"input": "$a.b", "initialValue": 0, "in": {"$add": ["$$value", "$$this"]}} + }, + doc={"a": {"b": [1, 2, 3]}}, + expected=6, + msg="$reduce should resolve a nested field path for input", + ), + ExpressionTestCase( + "composite_array_path", + expression={ + "$reduce": {"input": "$a.b", "initialValue": 0, "in": {"$add": ["$$value", "$$this"]}} + }, + doc={"a": [{"b": 1}, {"b": 2}, {"b": 3}]}, + expected=6, + msg="$reduce should resolve a composite array path for input", + ), +] + +# Property [Variable Input]: $reduce reads its input from bound and system variables. +LET_AND_VARIABLE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "let_variable", + expression={ + "$let": { + "vars": {"arr": "$values"}, + "in": { + "$reduce": { + "input": "$$arr", + "initialValue": 0, + "in": {"$add": ["$$value", "$$this"]}, + } + }, + } + }, + doc={"values": [1, 2, 3]}, + expected=6, + msg="$reduce should read input from a $let variable", + ), + ExpressionTestCase( + "root_variable", + expression={ + "$reduce": { + "input": "$$ROOT.values", + "initialValue": 0, + "in": {"$add": ["$$value", "$$this"]}, + } + }, + doc={"_id": 1, "values": [10, 20]}, + expected=30, + msg="$reduce should read input via $$ROOT", + ), +] + +# Property [Null/Missing Input]: a missing or removed input field propagates null. +NULL_MISSING_EXPR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "missing_field", + expression={ + "$reduce": { + "input": "$nonexistent", + "initialValue": 0, + "in": {"$add": ["$$value", "$$this"]}, + } + }, + doc={"other": 1}, + expected=None, + msg="$reduce should return null for a missing input field", + ), + ExpressionTestCase( + "missing_input_type_is_null", + expression={ + "$type": { + "$reduce": { + "input": "$nonexistent", + "initialValue": 0, + "in": {"$add": ["$$value", "$$this"]}, + } + } + }, + doc={"x": 1}, + expected="null", + msg="$reduce should produce null type for a missing input field", + ), + ExpressionTestCase( + "remove_variable", + expression={ + "$reduce": { + "input": "$$REMOVE", + "initialValue": 0, + "in": {"$add": ["$$value", "$$this"]}, + } + }, + doc={"x": 1}, + expected=None, + msg="$reduce should propagate null for $$REMOVE input", + ), +] + +# Property [In Expression Context]: 'in' can nest $reduce and reference outer fields. +MISC_EXPR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_reduce", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": 0, + "in": { + "$add": [ + "$$value", + { + "$reduce": { + "input": "$$this", + "initialValue": 0, + "in": {"$add": ["$$value", "$$this"]}, + } + }, + ] + }, + } + }, + doc={"arr": [[1, 2], [3, 4]]}, + expected=10, + msg="$reduce should support a nested $reduce", + ), + ExpressionTestCase( + "access_outer_field", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": 0, + "in": {"$add": ["$$value", {"$multiply": ["$$this", "$factor"]}]}, + } + }, + doc={"arr": [1, 2, 3], "factor": 10}, + expected=60, + msg="$reduce should access an outer document field in 'in'", + ), + ExpressionTestCase( + "field_ref_in_in", + expression={ + "$reduce": { + "input": [1, 2, 3, 4], + "initialValue": 0, + "in": {"$add": ["$$value", "$$this", "$val"]}, + } + }, + doc={"val": 1}, + expected=14, + msg="$reduce should add an outer field ref each iteration", + ), +] + +# Property [InitialValue Expression]: initialValue accepts field references and expressions. +INITIAL_VALUE_EXPR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "initialValue_from_field", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": "$init", + "in": {"$add": ["$$value", "$$this"]}, + } + }, + doc={"arr": [1, 2, 3], "init": 4}, + expected=10, + msg="$reduce should accept initialValue from a field reference", + ), + ExpressionTestCase( + "initialValue_from_expression", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": {"$multiply": [2, 2]}, + "in": {"$add": ["$$value", "$$this"]}, + } + }, + doc={"arr": [1, 2, 3]}, + expected=10, + msg="$reduce should accept initialValue from an expression", + ), +] + +# Property [Expression Input]: input accepts array expressions and $literal arrays. +EXPRESSION_INPUT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "array_expression_input", + expression={ + "$reduce": { + "input": ["$x", "$y", "$z"], + "initialValue": 0, + "in": {"$add": ["$$value", "$$this"]}, + } + }, + doc={"x": 1, "y": 2, "z": 3}, + expected=6, + msg="$reduce should resolve an array expression input", + ), + ExpressionTestCase( + "literal_input", + expression={ + "$reduce": { + "input": {"$literal": [5, 10, 15]}, + "initialValue": 0, + "in": {"$add": ["$$value", "$$this"]}, + } + }, + doc={}, + expected=30, + msg="$reduce should accept a $literal array input", + ), +] + +ALL_EXPR_TESTS = ( + FIELD_LOOKUP_TESTS + + LET_AND_VARIABLE_TESTS + + NULL_MISSING_EXPR_TESTS + + MISC_EXPR_TESTS + + INITIAL_VALUE_EXPR_TESTS + + EXPRESSION_INPUT_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_EXPR_TESTS)) +def test_reduce_expression(collection, test): + """Test $reduce with field paths and expressions.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/reduce/test_smoke_expression_reduce.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/reduce/test_smoke_expression_reduce.py index 68ba38ea0..85e26912f 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/array/reduce/test_smoke_expression_reduce.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/reduce/test_smoke_expression_reduce.py @@ -38,4 +38,4 @@ def test_smoke_expression_reduce(collection): ) expected = [{"_id": 1, "sum": 6}, {"_id": 2, "sum": 15}] - assertSuccess(result, expected, msg="Should support $reduce expression") + assertSuccess(result, expected, "$reduce should sum array elements in $project") diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/size/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/size/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/size/test_expression_size_bson_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/size/test_expression_size_bson_types.py new file mode 100644 index 000000000..06b0a9479 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/size/test_expression_size_bson_types.py @@ -0,0 +1,267 @@ +""" +BSON type tests for $size expression. + +Tests $size correctly counts array elements containing specific BSON types, +special numeric values, Decimal128 special values, and null byte strings. +""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_NAN, + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ONE_AND_HALF, + DOUBLE_NEGATIVE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, +) + +# Property [BSON Element Counting]: $size counts each array element regardless of its BSON type. +BSON_ELEMENT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "bson_types_mixed", + expression={"$size": "$arr"}, + doc={ + "arr": [ + Int64(1), + Decimal128("2"), + ObjectId("000000000000000000000001"), + datetime(2024, 1, 1, tzinfo=timezone.utc), + ] + }, + expected=4, + msg="$size should count mixed BSON-typed elements", + ), + ExpressionTestCase( + "minkey_maxkey", + expression={"$size": "$arr"}, + doc={"arr": [MinKey(), MaxKey()]}, + expected=2, + msg="$size should count MinKey and MaxKey elements", + ), + ExpressionTestCase( + "timestamp_element", + expression={"$size": "$arr"}, + doc={"arr": [Timestamp(0, 0)]}, + expected=1, + msg="$size should count a Timestamp element", + ), + ExpressionTestCase( + "binary_element", + expression={"$size": "$arr"}, + doc={"arr": [Binary(b"\x00", 0)]}, + expected=1, + msg="$size should count a Binary element", + ), + ExpressionTestCase( + "regex_element", + expression={"$size": "$arr"}, + doc={"arr": [Regex(".*")]}, + expected=1, + msg="$size should count a Regex element", + ), + ExpressionTestCase( + "int64_element", + expression={"$size": "$arr"}, + doc={"arr": [Int64(1)]}, + expected=1, + msg="$size should count an Int64 element", + ), + ExpressionTestCase( + "decimal128_element", + expression={"$size": "$arr"}, + doc={"arr": [Decimal128("1")]}, + expected=1, + msg="$size should count a Decimal128 element", + ), + ExpressionTestCase( + "objectid_element", + expression={"$size": "$arr"}, + doc={"arr": [ObjectId()]}, + expected=1, + msg="$size should count an ObjectId element", + ), + ExpressionTestCase( + "datetime_element", + expression={"$size": "$arr"}, + doc={"arr": [datetime(2024, 1, 1, tzinfo=timezone.utc)]}, + expected=1, + msg="$size should count a datetime element", + ), +] + +# Property [Special Numeric Elements]: $size counts NaN, Infinity, and negative-zero elements. +SPECIAL_NUMERIC_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "special_numerics_mixed", + expression={"$size": "$arr"}, + doc={"arr": [DECIMAL128_NAN, FLOAT_NAN, FLOAT_INFINITY]}, + expected=3, + msg="$size should count special numeric elements", + ), + ExpressionTestCase( + "nan_element", + expression={"$size": "$arr"}, + doc={"arr": [FLOAT_NAN]}, + expected=1, + msg="$size should count a NaN element", + ), + ExpressionTestCase( + "inf_element", + expression={"$size": "$arr"}, + doc={"arr": [FLOAT_INFINITY]}, + expected=1, + msg="$size should count an Infinity element", + ), + ExpressionTestCase( + "neg_inf_element", + expression={"$size": "$arr"}, + doc={"arr": [FLOAT_NEGATIVE_INFINITY]}, + expected=1, + msg="$size should count a -Infinity element", + ), + ExpressionTestCase( + "neg_zero_element", + expression={"$size": "$arr"}, + doc={"arr": [DOUBLE_NEGATIVE_ZERO]}, + expected=1, + msg="$size should count a negative zero element", + ), +] + +# Property [Decimal128 Special Elements]: $size counts Decimal128 NaN and Infinity elements. +DECIMAL128_SPECIAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "decimal128_nan", + expression={"$size": "$arr"}, + doc={"arr": [DECIMAL128_NAN]}, + expected=1, + msg="$size should count a Decimal128 NaN element", + ), + ExpressionTestCase( + "decimal128_neg_nan", + expression={"$size": "$arr"}, + doc={"arr": [DECIMAL128_NEGATIVE_NAN]}, + expected=1, + msg="$size should count a Decimal128 -NaN element", + ), + ExpressionTestCase( + "decimal128_inf", + expression={"$size": "$arr"}, + doc={"arr": [DECIMAL128_INFINITY]}, + expected=1, + msg="$size should count a Decimal128 Infinity element", + ), + ExpressionTestCase( + "decimal128_neg_inf", + expression={"$size": "$arr"}, + doc={"arr": [DECIMAL128_NEGATIVE_INFINITY]}, + expected=1, + msg="$size should count a Decimal128 -Infinity element", + ), + ExpressionTestCase( + "decimal128_neg_zero", + expression={"$size": "$arr"}, + doc={"arr": [DECIMAL128_NEGATIVE_ZERO]}, + expected=1, + msg="$size should count a Decimal128 -0 element", + ), +] + +# Property [Top-Level Counting]: $size counts only top-level elements, not nested values. +NESTED_MIXED_BSON_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_mixed_bson", + expression={"$size": "$arr"}, + doc={ + "arr": [ + MinKey(), + {"a": [DECIMAL128_ONE_AND_HALF]}, + Int64(1), + datetime(2024, 1, 1, tzinfo=timezone.utc), + Binary(b"\x01", 0), + ] + }, + expected=5, + msg="$size should count nested mixed BSON elements at the top level", + ), +] + +# Property [Null Byte Strings]: $size counts elements whose string content contains null bytes. +NULL_BYTE_STRING_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_byte_strings", + expression={"$size": "$arr"}, + doc={ + "arr": [ + "\u0000", + "\u0000abcd", + "abcd\u0000", + "1\u00002\u00003\u00004", + ["\u0000", "\u0000abc", "abc\u0000", "1\u00002\u00003\u0000"], + {"test_doc": "1\u00002\u00003\u0000"}, + {"test_doc": ["\u0000"]}, + ] + }, + expected=7, + msg="$size should count elements whose strings contain null bytes", + ), +] + +ALL_BSON_TESTS = ( + BSON_ELEMENT_TESTS + + SPECIAL_NUMERIC_TESTS + + DECIMAL128_SPECIAL_TESTS + + NESTED_MIXED_BSON_TESTS + + NULL_BYTE_STRING_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_BSON_TESTS)) +def test_size_bson_insert(collection, test): + """Test $size BSON types with values from inserted documents.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result(result, expected=test.expected, msg=test.msg) + + +# Property [Literal BSON Input]: $size counts a literal array of BSON-typed elements. +BSON_LITERAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "literal_bson_mixed", + expression={ + "$size": { + "$literal": [ + Int64(1), + Decimal128("2"), + ObjectId("000000000000000000000001"), + datetime(2024, 1, 1, tzinfo=timezone.utc), + ] + } + }, + expected=4, + msg="$size should count a literal array of BSON-typed elements", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(BSON_LITERAL_TESTS)) +def test_size_bson_literal(collection, test): + """Test $size BSON types with literal values.""" + result = execute_expression(collection, test.expression) + assert_expression_result(result, expected=test.expected, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/size/test_expression_size_core_behavior.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/size/test_expression_size_core_behavior.py new file mode 100644 index 000000000..dcaceafb3 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/size/test_expression_size_core_behavior.py @@ -0,0 +1,200 @@ +""" +Core behavior tests for $size expression. + +Tests array length counting for various array types and sizes. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.lazy_payload import lazy +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Element Count]: $size returns the number of top-level elements in an array. +BASIC_SIZE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "empty_array", + expression={"$size": "$arr"}, + doc={"arr": []}, + expected=0, + msg="$size should return 0 for an empty array", + ), + ExpressionTestCase( + "single_element", + expression={"$size": "$arr"}, + doc={"arr": [1]}, + expected=1, + msg="$size should return 1 for a single-element array", + ), + ExpressionTestCase( + "single_null_element", + expression={"$size": "$arr"}, + doc={"arr": [None]}, + expected=1, + msg="$size should return 1 for a single null element", + ), + ExpressionTestCase( + "three_elements", + expression={"$size": "$arr"}, + doc={"arr": [1, 2, 3]}, + expected=3, + msg="$size should return 3 for a three-element array", + ), + ExpressionTestCase( + "five_elements", + expression={"$size": "$arr"}, + doc={"arr": [1, 2, 3, 4, 5]}, + expected=5, + msg="$size should return 5 for a five-element array", + ), + ExpressionTestCase( + "all_same", + expression={"$size": "$arr"}, + doc={"arr": [1, 1, 1, 1]}, + expected=4, + msg="$size should count duplicate elements", + ), + ExpressionTestCase( + "string_array", + expression={"$size": "$arr"}, + doc={"arr": ["a", "b", "c"]}, + expected=3, + msg="$size should count string elements", + ), +] + +# Property [Element Type Independence]: $size counts every element regardless of type. +ELEMENT_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "mixed_types", + expression={"$size": "$arr"}, + doc={"arr": [1, "two", True, None, {"a": 1}, [1, 2]]}, + expected=6, + msg="$size should count mixed-type elements", + ), + ExpressionTestCase( + "nested_arrays", + expression={"$size": "$arr"}, + doc={"arr": [[1, 2], [3, 4], [5, 6]]}, + expected=3, + msg="$size should count top-level elements without flattening", + ), + ExpressionTestCase( + "array_of_objects", + expression={"$size": "$arr"}, + doc={"arr": [{"a": 1}, {"b": 2}]}, + expected=2, + msg="$size should count object elements", + ), + ExpressionTestCase( + "array_of_nulls", + expression={"$size": "$arr"}, + doc={"arr": [None, None, None]}, + expected=3, + msg="$size should count null elements", + ), + ExpressionTestCase( + "array_with_empty_subarrays", + expression={"$size": "$arr"}, + doc={"arr": [[], [], []]}, + expected=3, + msg="$size should count empty subarray elements", + ), + ExpressionTestCase( + "empty_object_element", + expression={"$size": "$arr"}, + doc={"arr": [{}]}, + expected=1, + msg="$size should count an empty object element", + ), + ExpressionTestCase( + "empty_string_element", + expression={"$size": "$arr"}, + doc={"arr": [""]}, + expected=1, + msg="$size should count an empty string element", + ), + ExpressionTestCase( + "zero_element", + expression={"$size": "$arr"}, + doc={"arr": [0]}, + expected=1, + msg="$size should count a zero element", + ), + ExpressionTestCase( + "false_element", + expression={"$size": "$arr"}, + doc={"arr": [False]}, + expected=1, + msg="$size should count a false element", + ), + ExpressionTestCase( + "single_nested_array", + expression={"$size": "$arr"}, + doc={"arr": [[1, 2, 3]]}, + expected=1, + msg="$size should count a single nested array as one element", + ), +] + +# Property [Large Arrays]: $size returns the correct count for large arrays. +LARGE_ARRAY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "large_array", + expression={"$size": "$arr"}, + doc=lazy(lambda: {"arr": list(range(10_000))}), + expected=10_000, + msg="$size should count a large array's elements", + ), + ExpressionTestCase( + "large_string_element", + expression={"$size": "$arr"}, + doc=lazy(lambda: {"arr": ["x" * 1_000_000]}), + expected=1, + msg="$size should count a single large string element as one element", + ), +] + +ALL_TESTS = BASIC_SIZE_TESTS + ELEMENT_TYPE_TESTS + LARGE_ARRAY_TESTS + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_size_insert(collection, test): + """Test $size with values from inserted documents.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +# Property [Literal Input]: $size counts an array passed as a literal. +SIZE_LITERAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "literal_empty", + expression={"$size": {"$literal": []}}, + expected=0, + msg="$size should count a literal empty array", + ), + ExpressionTestCase( + "literal_three_elements", + expression={"$size": {"$literal": [1, 2, 3]}}, + expected=3, + msg="$size should count a literal array's elements", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(SIZE_LITERAL_TESTS)) +def test_size_literal(collection, test): + """Test $size with literal values.""" + result = execute_expression(collection, test.expression) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/size/test_expression_size_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/size/test_expression_size_errors.py new file mode 100644 index 000000000..426940fc7 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/size/test_expression_size_errors.py @@ -0,0 +1,222 @@ +""" +Error tests for $size expression. + +$size errors on non-array input (including null and missing) and wrong arity. +Unlike most operators, $size does NOT propagate null — it errors. +""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + EXPRESSION_TYPE_MISMATCH_ERROR, + SIZE_NOT_ARRAY_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Non-Array Rejection]: $size rejects any non-array input, including null. +NOT_ARRAY_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "string_input", + expression={"$size": "$arr"}, + doc={"arr": "hello"}, + error_code=SIZE_NOT_ARRAY_ERROR, + msg="$size should reject string input", + ), + ExpressionTestCase( + "int_input", + expression={"$size": "$arr"}, + doc={"arr": 42}, + error_code=SIZE_NOT_ARRAY_ERROR, + msg="$size should reject int input", + ), + ExpressionTestCase( + "double_input", + expression={"$size": "$arr"}, + doc={"arr": 3.14}, + error_code=SIZE_NOT_ARRAY_ERROR, + msg="$size should reject double input", + ), + ExpressionTestCase( + "bool_true_input", + expression={"$size": "$arr"}, + doc={"arr": True}, + error_code=SIZE_NOT_ARRAY_ERROR, + msg="$size should reject boolean true input", + ), + ExpressionTestCase( + "bool_false_input", + expression={"$size": "$arr"}, + doc={"arr": False}, + error_code=SIZE_NOT_ARRAY_ERROR, + msg="$size should reject boolean false input", + ), + ExpressionTestCase( + "null_input", + expression={"$size": "$arr"}, + doc={"arr": None}, + error_code=SIZE_NOT_ARRAY_ERROR, + msg="$size should reject null input without null propagation", + ), + ExpressionTestCase( + "object_input", + expression={"$size": "$arr"}, + doc={"arr": {"a": 1}}, + error_code=SIZE_NOT_ARRAY_ERROR, + msg="$size should reject object input", + ), + ExpressionTestCase( + "decimal128_input", + expression={"$size": "$arr"}, + doc={"arr": Decimal128("1")}, + error_code=SIZE_NOT_ARRAY_ERROR, + msg="$size should reject decimal128 input", + ), + ExpressionTestCase( + "int64_input", + expression={"$size": "$arr"}, + doc={"arr": Int64(1)}, + error_code=SIZE_NOT_ARRAY_ERROR, + msg="$size should reject int64 input", + ), + ExpressionTestCase( + "objectid_input", + expression={"$size": "$arr"}, + doc={"arr": ObjectId()}, + error_code=SIZE_NOT_ARRAY_ERROR, + msg="$size should reject objectid input", + ), + ExpressionTestCase( + "datetime_input", + expression={"$size": "$arr"}, + doc={"arr": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + error_code=SIZE_NOT_ARRAY_ERROR, + msg="$size should reject datetime input", + ), + ExpressionTestCase( + "binary_input", + expression={"$size": "$arr"}, + doc={"arr": Binary(b"x", 0)}, + error_code=SIZE_NOT_ARRAY_ERROR, + msg="$size should reject binary input", + ), + ExpressionTestCase( + "regex_input", + expression={"$size": "$arr"}, + doc={"arr": Regex("x")}, + error_code=SIZE_NOT_ARRAY_ERROR, + msg="$size should reject regex input", + ), + ExpressionTestCase( + "javascript_input", + expression={"$size": "$arr"}, + doc={"arr": Code("x")}, + error_code=SIZE_NOT_ARRAY_ERROR, + msg="$size should reject JavaScript code input", + ), + ExpressionTestCase( + "timestamp_input", + expression={"$size": "$arr"}, + doc={"arr": Timestamp(0, 0)}, + error_code=SIZE_NOT_ARRAY_ERROR, + msg="$size should reject timestamp input", + ), + ExpressionTestCase( + "minkey_input", + expression={"$size": "$arr"}, + doc={"arr": MinKey()}, + error_code=SIZE_NOT_ARRAY_ERROR, + msg="$size should reject minkey input", + ), + ExpressionTestCase( + "maxkey_input", + expression={"$size": "$arr"}, + doc={"arr": MaxKey()}, + error_code=SIZE_NOT_ARRAY_ERROR, + msg="$size should reject maxkey input", + ), +] + +# Property [Non-Array Field Rejection]: $size errors on a non-array or missing field path. +FIELD_EXPR_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "field_ref_single_element_array_wrapped", + expression={"$size": ["$a"]}, + doc={"a": 1}, + error_code=SIZE_NOT_ARRAY_ERROR, + msg=( + "$size should reject a non-array field ref even when wrapped in a " + "single-element argument array (array-unwrap syntax)" + ), + ), + ExpressionTestCase( + "missing_field", + expression={"$size": "$nonexistent"}, + doc={"other": 1}, + error_code=SIZE_NOT_ARRAY_ERROR, + msg="$size should reject a missing field", + ), + ExpressionTestCase( + "nested_missing_field", + expression={"$size": "$a.b"}, + doc={"a": {}}, + error_code=SIZE_NOT_ARRAY_ERROR, + msg="$size should reject a missing nested field", + ), +] + +# Property [Literal Input]: $size rejects a non-array literal argument. +LITERAL_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "literal_string", + expression={"$size": "hello"}, + error_code=SIZE_NOT_ARRAY_ERROR, + msg="$size should reject a non-array literal", + ), +] + +# Property [Arity]: $size requires exactly one argument. +ARITY_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "zero_args", + expression={"$size": []}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="$size should reject zero arguments", + ), + ExpressionTestCase( + "two_args", + expression={"$size": [1, 2]}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="$size should reject two arguments", + ), +] + +INSERT_ERROR_TESTS = NOT_ARRAY_ERROR_TESTS + FIELD_EXPR_ERROR_TESTS + + +@pytest.mark.parametrize("test", pytest_params(INSERT_ERROR_TESTS)) +def test_size_insert(collection, test): + """Test $size error cases with values from inserted documents.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +@pytest.mark.parametrize("test", pytest_params(LITERAL_ERROR_TESTS + ARITY_ERROR_TESTS)) +def test_size_literal(collection, test): + """Test $size error cases with literal values, including wrong arity.""" + result = execute_expression(collection, test.expression) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/size/test_expression_size_expressions.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/size/test_expression_size_expressions.py new file mode 100644 index 000000000..e763c3507 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/size/test_expression_size_expressions.py @@ -0,0 +1,67 @@ +""" +Expression and field path tests for $size expression. + +Tests nested expressions, field path lookups, and composite paths. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Variable Input]: $size counts an array bound to a $let variable. +LET_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "size_let_variable", + expression={"$let": {"vars": {"arr": "$arr"}, "in": {"$size": "$$arr"}}}, + doc={"arr": [1, 2, 3]}, + expected=3, + msg="$size should count an array bound to a $let variable", + ), +] + +# Property [Field Path Input]: $size resolves a field path to an array before counting. +FIELD_LOOKUP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_field_path", + expression={"$size": "$a.b"}, + doc={"a": {"b": [10, 20, 30]}}, + expected=3, + msg="$size should count an array at a nested field path", + ), + ExpressionTestCase( + "composite_array_path", + expression={"$size": "$a.b"}, + doc={"a": [{"b": 1}, {"b": 2}, {"b": 3}]}, + expected=3, + msg="$size should count an array resolved from a composite path", + ), +] + +# Property [Nested Operator Input]: $size counts an array produced by a nested expression. +NESTED_OPERATOR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_operator", + expression={"$size": [[1, 2, 3, {"$size": "$arr"}]]}, + doc={"arr": [1, 2, 3]}, + expected=4, + msg="$size should count an array containing a nested $size result", + ), +] + +ALL_EXPR_TESTS = LET_TESTS + FIELD_LOOKUP_TESTS + NESTED_OPERATOR_TESTS + + +@pytest.mark.parametrize("test", pytest_params(ALL_EXPR_TESTS)) +def test_size_expression(collection, test): + """Test $size with field paths and expressions.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/size/test_smoke_expression_size.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/size/test_smoke_expression_size.py index b033165a9..da0dea578 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/array/size/test_smoke_expression_size.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/size/test_smoke_expression_size.py @@ -28,4 +28,4 @@ def test_smoke_expression_size(collection): ) expected = [{"_id": 1, "arraySize": 3}, {"_id": 2, "arraySize": 5}] - assertSuccess(result, expected, msg="Should support $size expression") + assertSuccess(result, expected, "$size should return element counts in $project") diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/slice/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/slice/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/slice/test_expression_slice_core_behavior.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/slice/test_expression_slice_core_behavior.py new file mode 100644 index 000000000..e78ab5dec --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/slice/test_expression_slice_core_behavior.py @@ -0,0 +1,439 @@ +""" +Core behavior tests for $slice expression. + +Tests 2-arg form (positive/negative n, n exceeds length, zero n, empty array), +3-arg form (position, negative position, n exceeds remaining, empty/single-element), +nested mixed arrays, large arrays, and literal array/index arguments. +""" + +import pytest +from bson import Decimal128 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import INT32_MAX + +# Property [Positive n]: a positive n returns the first n elements, capped at array length. +POSITIVE_N_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "first_1", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3, 4, 5], "n": 1}, + expected=[1], + msg="$slice should return the first element", + ), + ExpressionTestCase( + "first_2", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3, 4, 5], "n": 2}, + expected=[1, 2], + msg="$slice should return the first 2 elements", + ), + ExpressionTestCase( + "first_3", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3, 4, 5], "n": 3}, + expected=[1, 2, 3], + msg="$slice should return the first 3 elements", + ), + ExpressionTestCase( + "first_all", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3], "n": 3}, + expected=[1, 2, 3], + msg="$slice should return all elements", + ), + ExpressionTestCase( + "first_single", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [42], "n": 1}, + expected=[42], + msg="$slice should return a single element", + ), + ExpressionTestCase( + "first_strings", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": ["a", "b", "c"], "n": 2}, + expected=["a", "b"], + msg="$slice should return the first 2 strings", + ), + ExpressionTestCase( + "first_mixed", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, "two", True, None], "n": 3}, + expected=[1, "two", True], + msg="$slice should return the first 3 mixed elements", + ), +] + +# Property [Negative n]: a negative n returns the last |n| elements, capped at array length. +NEGATIVE_N_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "last_1", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3, 4, 5], "n": -1}, + expected=[5], + msg="$slice should return the last element", + ), + ExpressionTestCase( + "last_2", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3, 4, 5], "n": -2}, + expected=[4, 5], + msg="$slice should return the last 2 elements", + ), + ExpressionTestCase( + "last_3", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3, 4, 5], "n": -3}, + expected=[3, 4, 5], + msg="$slice should return the last 3 elements", + ), + ExpressionTestCase( + "last_all", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3], "n": -3}, + expected=[1, 2, 3], + msg="$slice should return all elements via negative n", + ), + ExpressionTestCase( + "last_single", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [42], "n": -1}, + expected=[42], + msg="$slice should return a single element via -1", + ), +] + +# Property [n Exceeds Length]: an n larger than the array returns the whole array. +N_EXCEEDS_LENGTH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "pos_n_exceeds", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3], "n": 10}, + expected=[1, 2, 3], + msg="$slice should return all when n exceeds length", + ), + ExpressionTestCase( + "neg_n_exceeds", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3], "n": -10}, + expected=[1, 2, 3], + msg="$slice should return all when negative n exceeds length", + ), + ExpressionTestCase( + "pos_n_int32_max", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3], "n": INT32_MAX}, + expected=[1, 2, 3], + msg="$slice should return all when n is INT32_MAX", + ), +] + +# Property [Zero n]: n of zero returns an empty array. +ZERO_N_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "zero_n", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3], "n": 0}, + expected=[], + msg="$slice should return empty for n=0", + ), + ExpressionTestCase( + "zero_n_empty", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [], "n": 0}, + expected=[], + msg="$slice should return empty for n=0 on an empty array", + ), +] + +# Property [Empty Array]: slicing an empty array returns an empty array. +EMPTY_ARRAY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "empty_pos_n", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [], "n": 5}, + expected=[], + msg="$slice should return empty for positive n on an empty array", + ), + ExpressionTestCase( + "empty_neg_n", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [], "n": -5}, + expected=[], + msg="$slice should return empty for negative n on an empty array", + ), +] + +# Property [Position]: the 3-arg form starts slicing at the given position. +POSITION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "pos_0_n_2", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3, 4, 5], "pos": 0, "n": 2}, + expected=[1, 2], + msg="$slice should slice from position 0", + ), + ExpressionTestCase( + "pos_1_n_2", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3, 4, 5], "pos": 1, "n": 2}, + expected=[2, 3], + msg="$slice should slice from position 1", + ), + ExpressionTestCase( + "pos_2_n_3", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3, 4, 5], "pos": 2, "n": 3}, + expected=[3, 4, 5], + msg="$slice should slice from position 2", + ), + ExpressionTestCase( + "pos_0_n_all", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": 0, "n": 3}, + expected=[1, 2, 3], + msg="$slice should slice all from position 0", + ), + ExpressionTestCase( + "pos_last_n_1", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": 2, "n": 1}, + expected=[3], + msg="$slice should slice the last element via position", + ), +] + +# Property [Negative Position]: a negative position counts from the end and clamps to the start. +NEGATIVE_POSITION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "neg_pos_1_n_1", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3, 4, 5], "pos": -1, "n": 1}, + expected=[5], + msg="$slice should slice from position -1", + ), + ExpressionTestCase( + "neg_pos_2_n_2", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3, 4, 5], "pos": -2, "n": 2}, + expected=[4, 5], + msg="$slice should slice from position -2", + ), + ExpressionTestCase( + "neg_pos_3_n_2", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3, 4, 5], "pos": -3, "n": 2}, + expected=[3, 4], + msg="$slice should slice from position -3", + ), + ExpressionTestCase( + "neg_pos_exceeds_n_2", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": -10, "n": 2}, + expected=[1, 2], + msg="$slice should clamp a negative position to the start", + ), + ExpressionTestCase( + "neg_pos_all", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": -3, "n": 3}, + expected=[1, 2, 3], + msg="$slice should slice all from a negative position", + ), +] + +# Property [Position n Exceeds]: n beyond the remaining elements returns what remains. +POSITION_N_EXCEEDS_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "pos_n_exceeds_remaining", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3, 4, 5], "pos": 3, "n": 10}, + expected=[4, 5], + msg="$slice should return the remaining elements when n exceeds", + ), + ExpressionTestCase( + "pos_beyond_array", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": 10, "n": 2}, + expected=[], + msg="$slice should return empty when position is beyond the array", + ), + ExpressionTestCase( + "pos_int32_max", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": INT32_MAX, "n": 2}, + expected=[], + msg="$slice should return empty when position is INT32_MAX", + ), + ExpressionTestCase( + "pos_eq_length", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3, 4, 5], "pos": 5, "n": 3}, + expected=[], + msg="$slice should return empty when position equals array length", + ), +] + +# Property [Empty Array With Position]: the 3-arg form on an empty array returns an empty array. +EMPTY_ARRAY_POSITION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "empty_pos_0", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [], "pos": 0, "n": 3}, + expected=[], + msg="$slice should return empty for an empty array with position 0", + ), + ExpressionTestCase( + "empty_neg_pos", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [], "pos": -1, "n": 3}, + expected=[], + msg="$slice should return empty for an empty array with a negative position", + ), + ExpressionTestCase( + "empty_pos_beyond", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [], "pos": 5, "n": 3}, + expected=[], + msg="$slice should return empty for an empty array with position beyond", + ), +] + +# Property [Single Element With Position]: position selects or misses the single element. +SINGLE_ELEMENT_POSITION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "single_pos_0_n_1", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": ["only"], "pos": 0, "n": 1}, + expected=["only"], + msg="$slice should return the element at position 0", + ), + ExpressionTestCase( + "single_neg_1_n_1", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": ["only"], "pos": -1, "n": 1}, + expected=["only"], + msg="$slice should return the element via a negative position", + ), + ExpressionTestCase( + "single_pos_past_end", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": ["only"], "pos": 1, "n": 1}, + expected=[], + msg="$slice should return empty when position is past the single element", + ), +] + +# Property [Element Preservation]: sliced elements retain their type and value. +NESTED_MIXED_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "mixed_bson_slice", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, "two", {"a": 1}, [3, 4], True, None, Decimal128("5.5")], "n": 4}, + expected=[1, "two", {"a": 1}, [3, 4]], + msg="$slice should slice mixed BSON types", + ), + ExpressionTestCase( + "mixed_bson_slice_neg", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, "two", {"a": 1}, [3, 4], True, None, Decimal128("5.5")], "n": -3}, + expected=[True, None, Decimal128("5.5")], + msg="$slice should slice the last 3 mixed BSON types", + ), + ExpressionTestCase( + "deeply_nested_slice", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [[[1, 2], [3, 4]], [[5, 6]], "end"], "n": 2}, + expected=[[[1, 2], [3, 4]], [[5, 6]]], + msg="$slice should slice deeply nested arrays", + ), +] + +# Property [Large Array]: slicing works on large arrays. +LARGE_ARRAY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "large_first_5", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": list(range(10_000)), "n": 5}, + expected=[0, 1, 2, 3, 4], + msg="$slice should slice the first 5 from a large array", + ), + ExpressionTestCase( + "large_last_5", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": list(range(10_000)), "n": -5}, + expected=[9_995, 9_996, 9_997, 9_998, 9_999], + msg="$slice should slice the last 5 from a large array", + ), + ExpressionTestCase( + "large_pos_middle", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": list(range(10_000)), "pos": 5_000, "n": 3}, + expected=[5_000, 5_001, 5_002], + msg="$slice should slice from the middle of a large array", + ), +] + +ALL_TESTS = ( + POSITIVE_N_TESTS + + NEGATIVE_N_TESTS + + N_EXCEEDS_LENGTH_TESTS + + ZERO_N_TESTS + + EMPTY_ARRAY_TESTS + + POSITION_TESTS + + NEGATIVE_POSITION_TESTS + + POSITION_N_EXCEEDS_TESTS + + EMPTY_ARRAY_POSITION_TESTS + + SINGLE_ELEMENT_POSITION_TESTS + + NESTED_MIXED_TESTS + + LARGE_ARRAY_TESTS +) + +# Property [Literal Arguments]: $slice accepts literal array and index arguments. +LITERAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "literal_first_1", + expression={"$slice": [[1, 2, 3, 4, 5], 1]}, + expected=[1], + msg="$slice should return the first element from literal arguments", + ), + ExpressionTestCase( + "literal_last_1", + expression={"$slice": [[1, 2, 3, 4, 5], -1]}, + expected=[5], + msg="$slice should return the last element from literal arguments", + ), + ExpressionTestCase( + "literal_pos_0_n_2", + expression={"$slice": [[1, 2, 3, 4, 5], 0, 2]}, + expected=[1, 2], + msg="$slice should slice from a literal position", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(LITERAL_TESTS)) +def test_slice_literal(collection, test): + """Test $slice with literal values.""" + result = execute_expression(collection, test.expression) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_slice_insert(collection, test): + """Test $slice with values from inserted documents.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/slice/test_expression_slice_element_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/slice/test_expression_slice_element_types.py new file mode 100644 index 000000000..06feafc73 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/slice/test_expression_slice_element_types.py @@ -0,0 +1,313 @@ +""" +Numeric type and element type preservation tests for $slice expression. + +Tests that n/position accept any integral numeric type (normalizing non-canonical +forms like -0.0) and that sliced elements, including special numeric values, retain +their original BSON type when passed as field references or literal arguments. +""" + +from datetime import datetime, timezone +from uuid import UUID + +import pytest +from bson import Binary, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ONE_AND_HALF, + DECIMAL128_TWO_AND_HALF, + DOUBLE_NEGATIVE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, +) + +# Property [Numeric Argument Types]: n and position accept any integral numeric type, +# and non-canonical representations of an integer (negative zero, alternate Decimal128 +# exponent forms) normalize to their integer value. +NUMERIC_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "n_int64", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3], "n": Int64(2)}, + expected=[1, 2], + msg="$slice should accept Int64 n", + ), + ExpressionTestCase( + "n_double_integral", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3], "n": 2.0}, + expected=[1, 2], + msg="$slice should accept an integral double n", + ), + ExpressionTestCase( + "n_decimal128_integral", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3], "n": Decimal128("2")}, + expected=[1, 2], + msg="$slice should accept Decimal128 n", + ), + ExpressionTestCase( + "n_neg_zero_double", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3], "n": DOUBLE_NEGATIVE_ZERO}, + expected=[], + msg="$slice should treat -0.0 n as 0", + ), + ExpressionTestCase( + "n_neg_zero_decimal128", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3], "n": DECIMAL128_NEGATIVE_ZERO}, + expected=[], + msg="$slice should treat decimal128 -0 n as 0", + ), + ExpressionTestCase( + "pos_int64", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3, 4, 5], "pos": Int64(1), "n": 2}, + expected=[2, 3], + msg="$slice should accept Int64 position", + ), + ExpressionTestCase( + "pos_double_integral", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3, 4, 5], "pos": 1.0, "n": 2}, + expected=[2, 3], + msg="$slice should accept an integral double position", + ), + ExpressionTestCase( + "pos_decimal128_integral", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3, 4, 5], "pos": Decimal128("1"), "n": 2}, + expected=[2, 3], + msg="$slice should accept Decimal128 position", + ), + ExpressionTestCase( + "pos_neg_zero_double", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": DOUBLE_NEGATIVE_ZERO, "n": 2}, + expected=[1, 2], + msg="$slice should treat -0.0 position as 0", + ), + ExpressionTestCase( + "pos_neg_zero_decimal128", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": DECIMAL128_NEGATIVE_ZERO, "n": 2}, + expected=[1, 2], + msg="$slice should treat Decimal128 -0 position as 0", + ), + ExpressionTestCase( + "pos_decimal128_10E_neg1", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": Decimal128("10E-1"), "n": 2}, + expected=[2, 3], + msg="$slice should treat Decimal128 10E-1 position as 1", + ), +] + +# Property [Element Preservation]: sliced elements retain their original BSON type and value. +ELEMENT_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "elem_int64", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [Int64(99), Int64(100)], "n": 1}, + expected=[Int64(99)], + msg="$slice should preserve Int64 elements", + ), + ExpressionTestCase( + "elem_decimal128", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [DECIMAL128_ONE_AND_HALF, DECIMAL128_TWO_AND_HALF], "n": 1}, + expected=[DECIMAL128_ONE_AND_HALF], + msg="$slice should preserve Decimal128 elements", + ), + ExpressionTestCase( + "elem_datetime", + expression={"$slice": ["$arr", "$n"]}, + doc={ + "arr": [ + datetime(2024, 1, 1, tzinfo=timezone.utc), + datetime(2024, 2, 1, tzinfo=timezone.utc), + ], + "n": 1, + }, + expected=[datetime(2024, 1, 1, tzinfo=timezone.utc)], + msg="$slice should preserve datetime elements", + ), + ExpressionTestCase( + "elem_objectid", + expression={"$slice": ["$arr", "$n"]}, + doc={ + "arr": [ObjectId("000000000000000000000001"), ObjectId("000000000000000000000002")], + "n": 1, + }, + expected=[ObjectId("000000000000000000000001")], + msg="$slice should preserve ObjectId elements", + ), + ExpressionTestCase( + "elem_binary", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [Binary(b"\x01", 0), Binary(b"\x02", 0)], "n": 1}, + expected=[b"\x01"], + msg="$slice should preserve binary elements", + ), + ExpressionTestCase( + "elem_regex", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [Regex("^a", "i"), Regex("^b", "i")], "n": 1}, + expected=[Regex("^a", "i")], + msg="$slice should preserve regex elements", + ), + ExpressionTestCase( + "elem_timestamp", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [Timestamp(1, 1), Timestamp(2, 2)], "n": 1}, + expected=[Timestamp(1, 1)], + msg="$slice should preserve timestamp elements", + ), + ExpressionTestCase( + "elem_minkey", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [MinKey(), 1], "n": 1}, + expected=[MinKey()], + msg="$slice should preserve MinKey elements", + ), + ExpressionTestCase( + "elem_maxkey", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, MaxKey()], "n": -1}, + expected=[MaxKey()], + msg="$slice should preserve MaxKey elements", + ), + ExpressionTestCase( + "elem_bool", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [True, False], "n": 1}, + expected=[True], + msg="$slice should preserve bool elements", + ), + ExpressionTestCase( + "elem_null", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [None, 1], "n": 1}, + expected=[None], + msg="$slice should preserve null elements", + ), + ExpressionTestCase( + "elem_nested_array", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [[1, 2], [3, 4]], "n": 1}, + expected=[[1, 2]], + msg="$slice should preserve nested array elements", + ), + ExpressionTestCase( + "elem_object", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [{"a": 1}, {"b": 2}], "n": 1}, + expected=[{"a": 1}], + msg="$slice should preserve object elements", + ), + ExpressionTestCase( + "elem_uuid", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [Binary.from_uuid(UUID("01234567-89ab-cdef-fedc-ba9876543210")), 1], "n": 1}, + expected=[Binary.from_uuid(UUID("01234567-89ab-cdef-fedc-ba9876543210"))], + msg="$slice should preserve UUID binary elements", + ), + ExpressionTestCase( + "elem_float_nan", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [FLOAT_NAN, 1, 2], "n": 1}, + expected=[pytest.approx(FLOAT_NAN, nan_ok=True)], + msg="$slice should preserve NaN elements", + ), + ExpressionTestCase( + "elem_float_infinity", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [FLOAT_INFINITY, 1], "n": 1}, + expected=[FLOAT_INFINITY], + msg="$slice should preserve Infinity elements", + ), + ExpressionTestCase( + "elem_float_neg_infinity", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [FLOAT_NEGATIVE_INFINITY, 1], "n": 1}, + expected=[FLOAT_NEGATIVE_INFINITY], + msg="$slice should preserve -Infinity elements", + ), + ExpressionTestCase( + "elem_decimal128_nan", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [DECIMAL128_NAN, 1], "n": 1}, + expected=[DECIMAL128_NAN], + msg="$slice should preserve Decimal128 NaN elements", + ), + ExpressionTestCase( + "elem_decimal128_infinity", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [DECIMAL128_INFINITY, 1], "n": 1}, + expected=[DECIMAL128_INFINITY], + msg="$slice should preserve Decimal128 Infinity elements", + ), + ExpressionTestCase( + "elem_decimal128_neg_infinity", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [DECIMAL128_NEGATIVE_INFINITY, 1], "n": 1}, + expected=[DECIMAL128_NEGATIVE_INFINITY], + msg="$slice should preserve Decimal128 -Infinity elements", + ), +] + +ALL_TESTS = NUMERIC_TYPE_TESTS + ELEMENT_TYPE_TESTS + +# Property [Literal Arguments]: $slice accepts literal arrays and numeric arguments. +LITERAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "literal_n_int64", + expression={"$slice": [[1, 2, 3], Int64(2)]}, + expected=[1, 2], + msg="$slice should accept a literal Int64 n", + ), + ExpressionTestCase( + "literal_elem_int64", + expression={"$slice": [[Int64(99), Int64(100)], 1]}, + expected=[Int64(99)], + msg="$slice should preserve Int64 elements from a literal array", + ), + ExpressionTestCase( + "literal_elem_decimal128_neg_infinity", + expression={"$slice": [[DECIMAL128_NEGATIVE_INFINITY, 1], 1]}, + expected=[DECIMAL128_NEGATIVE_INFINITY], + msg="$slice should preserve a Decimal128 -Infinity element from a literal array", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(LITERAL_TESTS)) +def test_slice_literal(collection, test): + """Test $slice element/numeric types with literal values.""" + result = execute_expression(collection, test.expression) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_slice_insert(collection, test): + """Test $slice element/numeric types with values from inserted documents.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/slice/test_expression_slice_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/slice/test_expression_slice_errors.py new file mode 100644 index 000000000..04530a321 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/slice/test_expression_slice_errors.py @@ -0,0 +1,433 @@ +""" +Error tests for $slice expression. + +Tests non-array first argument, non-numeric/non-integral n, non-positive n +in 3-arg form, wrong arity errors, and a field path resolving to an +invalid-type n. Position-argument errors are in test_expression_slice_position_errors.py. +""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + EXPRESSION_ARITY_ERROR, + EXPRESSION_SLICE_ARG_NOT_INTEGRAL_ERROR, + EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, + EXPRESSION_SLICE_N_NOT_POSITIVE_ERROR, + SLICE_INVALID_ARGUMENT_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_HALF, + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_ZERO, + DOUBLE_NEGATIVE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + INT64_MAX, +) + +# Property [Positive n Required]: in the 3-arg form, a non-positive n is rejected. +N_NOT_POSITIVE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "pos_0_n_0", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": 0, "n": 0}, + error_code=EXPRESSION_SLICE_N_NOT_POSITIVE_ERROR, + msg="$slice should reject n=0 in the 3-arg form", + ), + ExpressionTestCase( + "pos_1_n_0", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": 1, "n": 0}, + error_code=EXPRESSION_SLICE_N_NOT_POSITIVE_ERROR, + msg="$slice should reject n=0 with position 1", + ), + ExpressionTestCase( + "pos_0_n_neg", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": 0, "n": -1}, + error_code=EXPRESSION_SLICE_N_NOT_POSITIVE_ERROR, + msg="$slice should reject negative n in the 3-arg form", + ), + ExpressionTestCase( + "pos_1_n_neg", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": 1, "n": -2}, + error_code=EXPRESSION_SLICE_N_NOT_POSITIVE_ERROR, + msg="$slice should reject n=-2 in the 3-arg form", + ), + ExpressionTestCase( + "neg_pos_n_neg", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": -1, "n": -1}, + error_code=EXPRESSION_SLICE_N_NOT_POSITIVE_ERROR, + msg="$slice should reject negative n with a negative position", + ), + ExpressionTestCase( + "n_neg_zero_double_3arg", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": 0, "n": DOUBLE_NEGATIVE_ZERO}, + error_code=EXPRESSION_SLICE_N_NOT_POSITIVE_ERROR, + msg="$slice should reject -0.0 n in the 3-arg form", + ), + ExpressionTestCase( + "n_neg_zero_decimal128_3arg", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": 0, "n": DECIMAL128_NEGATIVE_ZERO}, + error_code=EXPRESSION_SLICE_N_NOT_POSITIVE_ERROR, + msg="$slice should reject Decimal128 -0 n in the 3-arg form", + ), +] + +# Property [Array Required]: a non-array, non-null first argument is rejected for every BSON type. +NOT_ARRAY_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "string_as_array", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": "hello", "n": 1}, + error_code=SLICE_INVALID_ARGUMENT_ERROR, + msg="$slice should reject a string as the array argument", + ), + ExpressionTestCase( + "int_as_array", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": 42, "n": 1}, + error_code=SLICE_INVALID_ARGUMENT_ERROR, + msg="$slice should reject an int as the array argument", + ), + ExpressionTestCase( + "double_as_array", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": 3.14, "n": 1}, + error_code=SLICE_INVALID_ARGUMENT_ERROR, + msg="$slice should reject a double as the array argument", + ), + ExpressionTestCase( + "bool_as_array", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": True, "n": 1}, + error_code=SLICE_INVALID_ARGUMENT_ERROR, + msg="$slice should reject a bool as the array argument", + ), + ExpressionTestCase( + "object_as_array", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": {"a": 1}, "n": 1}, + error_code=SLICE_INVALID_ARGUMENT_ERROR, + msg="$slice should reject an object as the array argument", + ), + ExpressionTestCase( + "decimal128_as_array", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": Decimal128("1"), "n": 1}, + error_code=SLICE_INVALID_ARGUMENT_ERROR, + msg="$slice should reject a decimal128 as the array argument", + ), + ExpressionTestCase( + "int64_as_array", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": Int64(1), "n": 1}, + error_code=SLICE_INVALID_ARGUMENT_ERROR, + msg="$slice should reject an int64 as the array argument", + ), + ExpressionTestCase( + "binary_as_array", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": Binary(b"x", 0), "n": 1}, + error_code=SLICE_INVALID_ARGUMENT_ERROR, + msg="$slice should reject a binary as the array argument", + ), + ExpressionTestCase( + "datetime_as_array", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": datetime(2024, 1, 1, tzinfo=timezone.utc), "n": 1}, + error_code=SLICE_INVALID_ARGUMENT_ERROR, + msg="$slice should reject a datetime as the array argument", + ), + ExpressionTestCase( + "objectid_as_array", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": ObjectId(), "n": 1}, + error_code=SLICE_INVALID_ARGUMENT_ERROR, + msg="$slice should reject an objectid as the array argument", + ), + ExpressionTestCase( + "regex_as_array", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": Regex("x"), "n": 1}, + error_code=SLICE_INVALID_ARGUMENT_ERROR, + msg="$slice should reject a regex as the array argument", + ), + ExpressionTestCase( + "javascript_as_array", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": Code("x"), "n": 1}, + error_code=SLICE_INVALID_ARGUMENT_ERROR, + msg="$slice should reject JavaScript code as the array argument", + ), + ExpressionTestCase( + "timestamp_as_array", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": Timestamp(0, 0), "n": 1}, + error_code=SLICE_INVALID_ARGUMENT_ERROR, + msg="$slice should reject a timestamp as the array argument", + ), + ExpressionTestCase( + "minkey_as_array", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": MinKey(), "n": 1}, + error_code=SLICE_INVALID_ARGUMENT_ERROR, + msg="$slice should reject minkey as the array argument", + ), + ExpressionTestCase( + "maxkey_as_array", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": MaxKey(), "n": 1}, + error_code=SLICE_INVALID_ARGUMENT_ERROR, + msg="$slice should reject maxkey as the array argument", + ), +] + +# Property [Numeric n]: a non-numeric n is rejected for every non-deprecated BSON type. +N_NOT_NUMERIC_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "n_string", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3], "n": "2"}, + error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, + msg="$slice should reject string n", + ), + ExpressionTestCase( + "n_bool", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3], "n": True}, + error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, + msg="$slice should reject bool n", + ), + ExpressionTestCase( + "n_array", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3], "n": [2]}, + error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, + msg="$slice should reject array n", + ), + ExpressionTestCase( + "n_object", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3], "n": {"a": 2}}, + error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, + msg="$slice should reject object n", + ), + ExpressionTestCase( + "n_datetime", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3], "n": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, + msg="$slice should reject datetime n", + ), + ExpressionTestCase( + "n_objectid", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3], "n": ObjectId()}, + error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, + msg="$slice should reject objectid n", + ), + ExpressionTestCase( + "n_binary", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3], "n": Binary(b"x", 0)}, + error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, + msg="$slice should reject binary n", + ), + ExpressionTestCase( + "n_regex", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3], "n": Regex("x")}, + error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, + msg="$slice should reject regex n", + ), + ExpressionTestCase( + "n_javascript", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3], "n": Code("x")}, + error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, + msg="$slice should reject JavaScript code n", + ), + ExpressionTestCase( + "n_timestamp", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3], "n": Timestamp(0, 0)}, + error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, + msg="$slice should reject timestamp n", + ), + ExpressionTestCase( + "n_minkey", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3], "n": MinKey()}, + error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, + msg="$slice should reject minkey n", + ), + ExpressionTestCase( + "n_maxkey", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3], "n": MaxKey()}, + error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, + msg="$slice should reject maxkey n", + ), +] + +# Property [32-bit Representability]: n must be a whole number representable as a +# signed 32-bit integer; fractional values, NaN/Infinity, and integral values outside +# the int32 range are all rejected with the same error. +N_NOT_INTEGRAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "n_fractional_double", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3], "n": 1.5}, + error_code=EXPRESSION_SLICE_ARG_NOT_INTEGRAL_ERROR, + msg="$slice should reject a fractional double n", + ), + ExpressionTestCase( + "n_fractional_decimal128", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3], "n": DECIMAL128_HALF}, + error_code=EXPRESSION_SLICE_ARG_NOT_INTEGRAL_ERROR, + msg="$slice should reject a fractional decimal128 n", + ), + ExpressionTestCase( + "n_nan", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3], "n": FLOAT_NAN}, + error_code=EXPRESSION_SLICE_ARG_NOT_INTEGRAL_ERROR, + msg="$slice should reject NaN n", + ), + ExpressionTestCase( + "n_inf", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3], "n": FLOAT_INFINITY}, + error_code=EXPRESSION_SLICE_ARG_NOT_INTEGRAL_ERROR, + msg="$slice should reject infinity n", + ), + ExpressionTestCase( + "n_neg_inf", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3], "n": FLOAT_NEGATIVE_INFINITY}, + error_code=EXPRESSION_SLICE_ARG_NOT_INTEGRAL_ERROR, + msg="$slice should reject -infinity n", + ), + ExpressionTestCase( + "n_decimal128_nan", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3], "n": DECIMAL128_NAN}, + error_code=EXPRESSION_SLICE_ARG_NOT_INTEGRAL_ERROR, + msg="$slice should reject decimal128 NaN n", + ), + ExpressionTestCase( + "n_decimal128_inf", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3], "n": DECIMAL128_INFINITY}, + error_code=EXPRESSION_SLICE_ARG_NOT_INTEGRAL_ERROR, + msg="$slice should reject decimal128 infinity n", + ), + ExpressionTestCase( + "n_int64_max", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3], "n": INT64_MAX}, + error_code=EXPRESSION_SLICE_ARG_NOT_INTEGRAL_ERROR, + msg="$slice should reject n that is a whole number outside the 32-bit integer range", + ), +] + +# Property [Field Expression n]: a field path that resolves to a non-numeric n is rejected. +FIELD_EXPR_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "composite_array_as_n", + expression={"$slice": [[1, 2, 3], "$x.y"]}, + doc={"x": [{"y": 1}, {"y": 2}]}, + error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, + msg="$slice should reject a composite array resolved as the n argument", + ), +] + +ALL_TESTS = ( + N_NOT_POSITIVE_ERROR_TESTS + + NOT_ARRAY_ERROR_TESTS + + N_NOT_NUMERIC_TESTS + + N_NOT_INTEGRAL_TESTS + + FIELD_EXPR_ERROR_TESTS +) + +# Property [Literal Arguments]: errors are raised the same way with literal arguments. +LITERAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "literal_string_as_array", + expression={"$slice": ["hello", 1]}, + error_code=SLICE_INVALID_ARGUMENT_ERROR, + msg="$slice should reject a literal string as the array argument", + ), + ExpressionTestCase( + "literal_pos_0_n_0", + expression={"$slice": [[1, 2, 3], 0, 0]}, + error_code=EXPRESSION_SLICE_N_NOT_POSITIVE_ERROR, + msg="$slice should reject literal n=0 in the 3-arg form", + ), + ExpressionTestCase( + "literal_n_fractional_double", + expression={"$slice": [[1, 2, 3], 1.5]}, + error_code=EXPRESSION_SLICE_ARG_NOT_INTEGRAL_ERROR, + msg="$slice should reject a literal fractional double n", + ), +] + +# Property [Arity]: $slice requires exactly two or three arguments. +ARITY_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "zero_args", + expression={"$slice": []}, + error_code=EXPRESSION_ARITY_ERROR, + msg="$slice should reject zero arguments", + ), + ExpressionTestCase( + "one_arg", + expression={"$slice": [[1, 2, 3]]}, + error_code=EXPRESSION_ARITY_ERROR, + msg="$slice should reject a single argument", + ), + ExpressionTestCase( + "four_args", + expression={"$slice": [[1, 2, 3], 0, 2, 1]}, + error_code=EXPRESSION_ARITY_ERROR, + msg="$slice should reject four arguments", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(LITERAL_TESTS + ARITY_ERROR_TESTS)) +def test_slice_literal(collection, test): + """Test $slice error cases with literal values, including wrong arity.""" + result = execute_expression(collection, test.expression) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_slice_insert(collection, test): + """Test $slice error cases with values from inserted documents.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/slice/test_expression_slice_expressions.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/slice/test_expression_slice_expressions.py new file mode 100644 index 000000000..5ba46134a --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/slice/test_expression_slice_expressions.py @@ -0,0 +1,76 @@ +""" +Expression and field path tests for $slice expression. + +Tests nested expressions, field path lookups, and composite paths. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Nested Expression]: $slice can consume the output of a nested $slice. +NESTED_EXPR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_slice_2_level", + expression={"$slice": [{"$slice": [[1, 2, 3, 4, 5], 4]}, -2]}, + expected=[3, 4], + msg="$slice should slice the output of a nested $slice", + ), + ExpressionTestCase( + "nested_slice_3_level", + expression={"$slice": [{"$slice": [{"$slice": [[1, 2, 3, 4, 5, 6, 7], 5]}, -4]}, 2]}, + expected=[2, 3], + msg="$slice should slice through three nested $slice levels", + ), +] + +# Property [Field Path Input]: $slice resolves a field path to the array argument. +FIELD_LOOKUP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_field_path", + expression={"$slice": ["$a.b", 2]}, + doc={"a": {"b": [10, 20, 30]}}, + expected=[10, 20], + msg="$slice should resolve a nested field path", + ), + ExpressionTestCase( + "deeply_nested_field", + expression={"$slice": ["$a.b.c", -2]}, + doc={"a": {"b": {"c": [5, 6, 7, 8]}}}, + expected=[7, 8], + msg="$slice should resolve a deeply nested field path", + ), + ExpressionTestCase( + "composite_array_path", + expression={"$slice": ["$a.b", 2]}, + doc={"a": [{"b": 10}, {"b": 20}, {"b": 30}]}, + expected=[10, 20], + msg="$slice should resolve a composite array path", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(NESTED_EXPR_TESTS)) +def test_slice_nested_expression(collection, test): + """Test $slice composed with other expressions.""" + result = execute_expression(collection, test.expression) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +@pytest.mark.parametrize("test", pytest_params(FIELD_LOOKUP_TESTS)) +def test_slice_field_lookup(collection, test): + """Test $slice with field path lookups from inserted documents.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/slice/test_expression_slice_null_missing.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/slice/test_expression_slice_null_missing.py new file mode 100644 index 000000000..77d164744 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/slice/test_expression_slice_null_missing.py @@ -0,0 +1,133 @@ +""" +Null and missing field handling tests for $slice expression. + +Tests that a null or missing array, n, or position (2-arg and 3-arg forms) propagates +to null, via both field references and literal arguments. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import MISSING + +# Property [Null Propagation]: a null array, null n, or null position (3-arg) yields null. +NULL_INSERT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_array_2arg", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": None, "n": 2}, + expected=None, + msg="$slice should return null for a null array in the 2-arg form", + ), + ExpressionTestCase( + "null_array_3arg", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": None, "pos": 0, "n": 2}, + expected=None, + msg="$slice should return null for a null array in the 3-arg form", + ), + ExpressionTestCase( + "null_n_2arg", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3], "n": None}, + expected=None, + msg="$slice should return null for a null n", + ), + ExpressionTestCase( + "null_pos_3arg", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": None, "n": 2}, + expected=None, + msg="$slice should return null for a null position in the 3-arg form", + ), +] + +# Property [Null Literal Propagation]: null literal arguments yield null. +NULL_LITERAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_array_2arg_literal", + expression={"$slice": [None, 2]}, + expected=None, + msg="$slice should return null for a literal null array in the 2-arg form", + ), + ExpressionTestCase( + "null_array_3arg_literal", + expression={"$slice": [None, 0, 2]}, + expected=None, + msg="$slice should return null for a literal null array in the 3-arg form", + ), + ExpressionTestCase( + "null_n_2arg_literal", + expression={"$slice": [[1, 2, 3], None]}, + expected=None, + msg="$slice should return null for a literal null n", + ), + ExpressionTestCase( + "null_pos_3arg_literal", + expression={"$slice": [[1, 2, 3], None, 2]}, + expected=None, + msg="$slice should return null for a literal null position in the 3-arg form", + ), + ExpressionTestCase( + "null_n_3arg_literal", + expression={"$slice": [[1, 2, 3], 0, None]}, + expected=None, + msg="$slice should return null for a literal null n in the 3-arg form", + ), +] + +# Property [Missing Field Propagation]: a missing field reference in any argument yields null. +MISSING_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "missing_array_2arg", + expression={"$slice": [MISSING, 2]}, + expected=None, + msg="$slice should return null for a missing array", + ), + ExpressionTestCase( + "missing_n_2arg", + expression={"$slice": [[1, 2, 3], MISSING]}, + expected=None, + msg="$slice should return null for a missing n in the 2-arg form", + ), + ExpressionTestCase( + "missing_pos_3arg", + expression={"$slice": [[1, 2, 3], MISSING, 2]}, + expected=None, + msg="$slice should return null for a missing position in the 3-arg form", + ), + ExpressionTestCase( + "missing_n_3arg", + expression={"$slice": [[1, 2, 3], 0, MISSING]}, + expected=None, + msg="$slice should return null for a missing n in the 3-arg form", + ), +] + +LITERAL_TESTS = NULL_LITERAL_TESTS + MISSING_TESTS + + +@pytest.mark.parametrize("test", pytest_params(LITERAL_TESTS)) +def test_slice_literal(collection, test): + """Test $slice null/missing with literal values.""" + result = execute_expression(collection, test.expression) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +@pytest.mark.parametrize("test", pytest_params(NULL_INSERT_TESTS)) +def test_slice_insert(collection, test): + """Test $slice null with values from inserted documents.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/slice/test_expression_slice_position_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/slice/test_expression_slice_position_errors.py new file mode 100644 index 000000000..ca77afa9a --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/slice/test_expression_slice_position_errors.py @@ -0,0 +1,189 @@ +""" +Position-argument error tests for $slice expression. + +Tests non-numeric and non-integral position rejection in the 3-arg form. +Array and n-argument errors are in test_expression_slice_errors.py. +""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + EXPRESSION_SLICE_ARG_NOT_INTEGRAL_ERROR, + EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + INT64_MAX, +) + +# Property [Numeric Position]: a non-numeric position is rejected for every BSON type. +POS_NOT_NUMERIC_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "pos_string", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": "1", "n": 2}, + error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, + msg="$slice should reject string position", + ), + ExpressionTestCase( + "pos_bool", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": True, "n": 2}, + error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, + msg="$slice should reject bool position", + ), + ExpressionTestCase( + "pos_array", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": [1], "n": 2}, + error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, + msg="$slice should reject array position", + ), + ExpressionTestCase( + "pos_object", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": {"a": 1}, "n": 2}, + error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, + msg="$slice should reject object position", + ), + ExpressionTestCase( + "pos_datetime", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": datetime(2024, 1, 1, tzinfo=timezone.utc), "n": 2}, + error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, + msg="$slice should reject datetime position", + ), + ExpressionTestCase( + "pos_objectid", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": ObjectId(), "n": 2}, + error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, + msg="$slice should reject objectid position", + ), + ExpressionTestCase( + "pos_binary", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": Binary(b"x", 0), "n": 2}, + error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, + msg="$slice should reject binary position", + ), + ExpressionTestCase( + "pos_regex", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": Regex("x"), "n": 2}, + error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, + msg="$slice should reject regex position", + ), + ExpressionTestCase( + "pos_javascript", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": Code("x"), "n": 2}, + error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, + msg="$slice should reject JavaScript code position", + ), + ExpressionTestCase( + "pos_timestamp", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": Timestamp(0, 0), "n": 2}, + error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, + msg="$slice should reject timestamp position", + ), + ExpressionTestCase( + "pos_minkey", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": MinKey(), "n": 2}, + error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, + msg="$slice should reject minkey position", + ), + ExpressionTestCase( + "pos_maxkey", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": MaxKey(), "n": 2}, + error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, + msg="$slice should reject maxkey position", + ), +] + +# Property [32-bit Representability]: position must be a whole number representable as +# a signed 32-bit integer; fractional values, NaN/Infinity, and integral values outside +# the int32 range are all rejected with the same error. +POS_NOT_INTEGRAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "pos_fractional", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": 1.5, "n": 2}, + error_code=EXPRESSION_SLICE_ARG_NOT_INTEGRAL_ERROR, + msg="$slice should reject a fractional position", + ), + ExpressionTestCase( + "pos_nan", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": FLOAT_NAN, "n": 2}, + error_code=EXPRESSION_SLICE_ARG_NOT_INTEGRAL_ERROR, + msg="$slice should reject NaN position", + ), + ExpressionTestCase( + "pos_inf", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": FLOAT_INFINITY, "n": 2}, + error_code=EXPRESSION_SLICE_ARG_NOT_INTEGRAL_ERROR, + msg="$slice should reject infinity position", + ), + ExpressionTestCase( + "pos_neg_inf", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": FLOAT_NEGATIVE_INFINITY, "n": 2}, + error_code=EXPRESSION_SLICE_ARG_NOT_INTEGRAL_ERROR, + msg="$slice should reject -infinity position", + ), + ExpressionTestCase( + "pos_decimal128_nan", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": DECIMAL128_NAN, "n": 2}, + error_code=EXPRESSION_SLICE_ARG_NOT_INTEGRAL_ERROR, + msg="$slice should reject decimal128 NaN position", + ), + ExpressionTestCase( + "pos_decimal128_inf", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": DECIMAL128_INFINITY, "n": 2}, + error_code=EXPRESSION_SLICE_ARG_NOT_INTEGRAL_ERROR, + msg="$slice should reject decimal128 infinity position", + ), + ExpressionTestCase( + "pos_int64_max", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": INT64_MAX, "n": 2}, + error_code=EXPRESSION_SLICE_ARG_NOT_INTEGRAL_ERROR, + msg=( + "$slice should reject a position that is a whole number outside the " + "32-bit integer range" + ), + ), +] + +ALL_TESTS = POS_NOT_NUMERIC_TESTS + POS_NOT_INTEGRAL_TESTS + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_slice_position_insert(collection, test): + """Test $slice position-argument error cases with values from inserted documents.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/slice/test_smoke_expression_slice.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/slice/test_smoke_expression_slice.py index a4971bf99..59b1f6ae3 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/array/slice/test_smoke_expression_slice.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/slice/test_smoke_expression_slice.py @@ -28,4 +28,4 @@ def test_smoke_expression_slice(collection): ) expected = [{"_id": 1, "sliced": [1, 2]}, {"_id": 2, "sliced": [10, 20]}] - assertSuccess(result, expected, msg="Should support $slice expression") + assertSuccess(result, expected, "$slice should return the first n elements in $project") diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_reduce.py b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_reduce.py new file mode 100644 index 000000000..bf61dc285 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_reduce.py @@ -0,0 +1,107 @@ +""" +Combination tests for $reduce composed with other operators. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Operator Composition]: $reduce composes correctly with $range, $map, $size, +# $cond, and comparison operators. +REDUCE_COMBINATION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="reduce_sum_on_range", + expression={ + "$reduce": { + "input": {"$range": [1, 6]}, + "initialValue": 0, + "in": {"$add": ["$$value", "$$this"]}, + } + }, + doc={"_placeholder": 1}, + expected=15, + msg="Should sum $range(1,6)", + ), + ExpressionTestCase( + id="reduce_on_map", + expression={ + "$reduce": { + "input": {"$map": {"input": "$arr", "in": {"$multiply": ["$$this", 2]}}}, + "initialValue": 0, + "in": {"$add": ["$$value", "$$this"]}, + } + }, + doc={"arr": [1, 2, 3]}, + expected=12, + msg="Should sum mapped array", + ), + ExpressionTestCase( + id="reduce_flatten_then_size", + expression={ + "$size": { + "$reduce": { + "input": "$arr", + "initialValue": [], + "in": {"$concatArrays": ["$$value", "$$this"]}, + } + } + }, + doc={"arr": [[1, 2], [3], [4, 5, 6]]}, + expected=6, + msg="Size of flattened array", + ), + ExpressionTestCase( + id="max_value", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": 0, + "in": { + "$cond": { + "if": {"$gt": ["$$this", "$$value"]}, + "then": "$$this", + "else": "$$value", + } + }, + } + }, + doc={"arr": [3, 1, 4, 1, 5, 9, 2, 6]}, + expected=9, + msg="Should find max value", + ), + ExpressionTestCase( + id="null_elements_count_non_null", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": 0, + "in": { + "$cond": { + "if": {"$ne": ["$$this", None]}, + "then": {"$add": ["$$value", 1]}, + "else": "$$value", + } + }, + } + }, + doc={"arr": [1, None, 2, None, 3]}, + expected=3, + msg="Should count non-null elements", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(REDUCE_COMBINATION_TESTS)) +def test_reduce_combination(collection, test): + """Test $reduce composed with other operators.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_size.py b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_size.py new file mode 100644 index 000000000..b3021ad17 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_size.py @@ -0,0 +1,68 @@ +""" +Combination tests for $size composed with other operators. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +SIZE_COMBINATION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="size_on_range", + expression={"$size": {"$range": ["$start", "$end"]}}, + doc={"start": 0, "end": 10}, + expected=10, + msg="Should return size of $range result", + ), + ExpressionTestCase( + id="size_on_split", + expression={"$size": {"$split": ["$str", ","]}}, + doc={"str": "a,b,c,d"}, + expected=4, + msg="Should return size of $split result", + ), + ExpressionTestCase( + id="size_on_slice", + expression={"$size": {"$slice": ["$arr", 3]}}, + doc={"arr": [1, 2, 3, 4, 5]}, + expected=3, + msg="Should return size of $slice result", + ), + ExpressionTestCase( + id="size_on_objectToArray", + expression={"$size": {"$objectToArray": "$obj"}}, + doc={"obj": {"a": 1, "b": 2}}, + expected=2, + msg="Should return size of $objectToArray result", + ), + ExpressionTestCase( + id="size_on_reverseArray", + expression={"$size": {"$reverseArray": "$arr"}}, + doc={"arr": [1, 2, 3]}, + expected=3, + msg="Should return size of $reverseArray result", + ), + ExpressionTestCase( + id="size_subtract", + expression={"$subtract": [{"$size": "$a"}, {"$size": "$b"}]}, + doc={"a": [1, 2, 3, 4, 5], "b": [1, 2]}, + expected=3, + msg="Should subtract two $size results", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(SIZE_COMBINATION_TESTS)) +def test_size_combination(collection, test): + """Test $size composed with other operators.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_slice.py b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_slice.py new file mode 100644 index 000000000..529efd554 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_slice.py @@ -0,0 +1,75 @@ +""" +Combination tests for $slice composed with other operators. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Composition]: $slice composes with array-producing and array-consuming operators. +SLICE_COMBINATION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "slice_on_concatArrays", + expression={"$slice": [{"$concatArrays": ["$a", "$b"]}, 3]}, + doc={"a": [1, 2], "b": [3, 4, 5]}, + expected=[1, 2, 3], + msg="$slice should slice the result of $concatArrays", + ), + ExpressionTestCase( + "slice_on_map", + expression={"$slice": [{"$map": {"input": "$a", "in": {"$multiply": ["$$this", 2]}}}, 2]}, + doc={"a": [1, 2, 3]}, + expected=[2, 4], + msg="$slice should slice the result of $map", + ), + ExpressionTestCase( + "slice_on_range", + expression={"$slice": [{"$range": ["$start", "$end"]}, -3]}, + doc={"start": 0, "end": 10}, + expected=[7, 8, 9], + msg="$slice should slice the result of $range", + ), + ExpressionTestCase( + "reduce_on_slice", + expression={ + "$reduce": { + "input": {"$slice": ["$a", 3]}, + "initialValue": 0, + "in": {"$add": ["$$value", "$$this"]}, + } + }, + doc={"a": [1, 2, 3, 4, 5]}, + expected=6, + msg="$reduce should consume the result of $slice", + ), + ExpressionTestCase( + "concatArrays_of_slices", + expression={"$concatArrays": [{"$slice": ["$a", 2]}, {"$slice": ["$b", -1]}]}, + doc={"a": [1, 2, 3], "b": [4, 5, 6]}, + expected=[1, 2, 6], + msg="$slice results should compose under $concatArrays", + ), + ExpressionTestCase( + "slice_on_filter", + expression={"$slice": [{"$filter": {"input": "$a", "cond": {"$gt": ["$$this", 2]}}}, 2]}, + doc={"a": [1, 2, 3, 4, 5]}, + expected=[3, 4], + msg="$slice should slice the result of $filter", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(SLICE_COMBINATION_TESTS)) +def test_slice_combination(collection, test): + """Test $slice composed with other operators.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) From f39baec05b5f3465d3f3c450b4b5f1f7c66132ac Mon Sep 17 00:00:00 2001 From: "Alina (Xi) Li" <96995091+alinaliBQ@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:24:25 -0700 Subject: [PATCH 31/35] migrate arrayElemAt, arrayToObject, concatArrays (#666) Signed-off-by: Alina (Xi) Li Co-authored-by: Alina (Xi) Li Co-authored-by: Leszek Kurzyna Signed-off-by: RyanGarfinkel <113050972+RyanGarfinkel@users.noreply.github.com> --- .../expressions/array/arrayElemAt/__init__.py | 0 .../test_arrayElemAt_core_behavior.py | 235 +++++++++ .../test_arrayElemAt_element_types.py | 157 ++++++ .../arrayElemAt/test_arrayElemAt_errors.py | 393 +++++++++++++++ .../test_arrayElemAt_expressions.py | 146 ++++++ .../test_arrayElemAt_null_missing.py | 60 +++ .../test_arrayElemAt_numeric_index.py | 156 ++++++ .../test_arrayElemAt_out_of_bounds.py | 101 ++++ ...rayElemAt.py => test_smoke_arrayElemAt.py} | 2 +- .../array/arrayToObject/__init__.py | 0 .../test_arrayToObject_bson_types.py | 463 ++++++++++++++++++ .../test_arrayToObject_core_behavior.py | 410 ++++++++++++++++ .../test_arrayToObject_errors.py | 449 +++++++++++++++++ .../test_arrayToObject_expressions.py | 182 +++++++ ...oObject.py => test_smoke_arrayToObject.py} | 4 +- .../array/concatArrays/__init__.py | 0 .../test_concatArrays_bson_types.py | 224 +++++++++ .../test_concatArrays_core_behavior.py | 361 ++++++++++++++ .../concatArrays/test_concatArrays_errors.py | 322 ++++++++++++ .../test_concatArrays_expressions.py | 328 +++++++++++++ ...atArrays.py => test_smoke_concatArrays.py} | 4 +- ...t_expressions_combination_arrayToObject.py | 73 +++ ...st_expressions_combination_concatArrays.py | 89 ++++ 23 files changed, 4156 insertions(+), 3 deletions(-) create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_arrayElemAt_core_behavior.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_arrayElemAt_element_types.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_arrayElemAt_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_arrayElemAt_expressions.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_arrayElemAt_null_missing.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_arrayElemAt_numeric_index.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_arrayElemAt_out_of_bounds.py rename documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/{test_smoke_expression_arrayElemAt.py => test_smoke_arrayElemAt.py} (88%) create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayToObject/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayToObject/test_arrayToObject_bson_types.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayToObject/test_arrayToObject_core_behavior.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayToObject/test_arrayToObject_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayToObject/test_arrayToObject_expressions.py rename documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayToObject/{test_smoke_expression_arrayToObject.py => test_smoke_arrayToObject.py} (88%) create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/concatArrays/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/concatArrays/test_concatArrays_bson_types.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/concatArrays/test_concatArrays_core_behavior.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/concatArrays/test_concatArrays_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/concatArrays/test_concatArrays_expressions.py rename documentdb_tests/compatibility/tests/core/operator/expressions/array/concatArrays/{test_smoke_expression_concatArrays.py => test_smoke_concatArrays.py} (87%) create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_arrayToObject.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_concatArrays.py diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_arrayElemAt_core_behavior.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_arrayElemAt_core_behavior.py new file mode 100644 index 000000000..2b1a34192 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_arrayElemAt_core_behavior.py @@ -0,0 +1,235 @@ +""" +Core behavior tests for $arrayElemAt expression. + +Tests basic positive/negative index access, duplicate values, and large arrays. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Positive Index]: $arrayElemAt returns the element at the given positive index. +POSITIVE_INDEX_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="first_element", + doc={"arr": [1, 2, 3], "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=1, + msg="$arrayElemAt should return first element", + ), + ExpressionTestCase( + id="second_element", + doc={"arr": [1, 2, 3], "idx": 1}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=2, + msg="$arrayElemAt should return second element", + ), + ExpressionTestCase( + id="last_element", + doc={"arr": [1, 2, 3], "idx": 2}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=3, + msg="$arrayElemAt should return last element", + ), + ExpressionTestCase( + id="single_element_array", + doc={"arr": [42], "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=42, + msg="$arrayElemAt should return single element", + ), + ExpressionTestCase( + id="string_elements", + doc={"arr": ["a", "b", "c"], "idx": 1}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected="b", + msg="$arrayElemAt should return string element", + ), + ExpressionTestCase( + id="mixed_types", + doc={"arr": [1, "two", 3.0, True], "idx": 2}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=3.0, + msg="$arrayElemAt should return element from mixed-type array", + ), + ExpressionTestCase( + id="nested_array_element", + doc={"arr": [[1, 2], [3, 4]], "idx": 1}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=[3, 4], + msg="$arrayElemAt should return nested array element", + ), + ExpressionTestCase( + id="nested_object_element", + doc={"arr": [{"a": 1}, {"b": 2}], "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected={"a": 1}, + msg="$arrayElemAt should return nested object element", + ), + ExpressionTestCase( + id="null_element_in_array", + doc={"arr": [None, 1, 2], "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=None, + msg="$arrayElemAt should return null element", + ), + ExpressionTestCase( + id="bool_element", + doc={"arr": [True, False], "idx": 1}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=False, + msg="$arrayElemAt should return bool element", + ), +] + +# Property [Negative Index]: $arrayElemAt counts from the end of the array for a negative index. +NEGATIVE_INDEX_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="last_via_neg1", + doc={"arr": [1, 2, 3], "idx": -1}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=3, + msg="$arrayElemAt should return last element via -1", + ), + ExpressionTestCase( + id="second_to_last", + doc={"arr": [1, 2, 3], "idx": -2}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=2, + msg="$arrayElemAt should return second to last", + ), + ExpressionTestCase( + id="first_via_neg_len", + doc={"arr": [1, 2, 3], "idx": -3}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=1, + msg="$arrayElemAt should return first via negative length", + ), + ExpressionTestCase( + id="single_element_neg1", + doc={"arr": [42], "idx": -1}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=42, + msg="$arrayElemAt should return single element via -1", + ), +] + +# Property [Duplicate Values]: $arrayElemAt selects by position, ignoring duplicate elements. +DUPLICATE_VALUE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="dup_first", + doc={"arr": [1, 1, 1], "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=1, + msg="$arrayElemAt is unaffected by duplicate elements at index 0", + ), + ExpressionTestCase( + id="dup_last", + doc={"arr": [1, 1, 1], "idx": 2}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=1, + msg="$arrayElemAt is unaffected by duplicate elements at the last index", + ), + ExpressionTestCase( + id="dup_neg", + doc={"arr": ["a", "a", "b", "a"], "idx": -1}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected="a", + msg="$arrayElemAt is unaffected by duplicate elements at a negative index", + ), +] + +# Property [Large Array]: $arrayElemAt resolves positions within large arrays. +_LARGE_ARRAY_SIZE = 20_000 +_LARGE_ARRAY = list(range(_LARGE_ARRAY_SIZE)) + +# Property [Large Arrays]: $concatArrays concatenates large arrays. +LARGE_ARRAY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="large_array_first", + doc={"arr": _LARGE_ARRAY, "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=0, + msg="$arrayElemAt should return first in large array", + ), + ExpressionTestCase( + id="large_array_last", + doc={"arr": _LARGE_ARRAY, "idx": _LARGE_ARRAY_SIZE - 1}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=_LARGE_ARRAY_SIZE - 1, + msg="$arrayElemAt should return last in large array", + ), + ExpressionTestCase( + id="large_array_neg1", + doc={"arr": _LARGE_ARRAY, "idx": -1}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=_LARGE_ARRAY_SIZE - 1, + msg="$arrayElemAt should return last via -1 in large array", + ), + ExpressionTestCase( + id="large_array_middle", + doc={"arr": _LARGE_ARRAY, "idx": _LARGE_ARRAY_SIZE // 2}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=_LARGE_ARRAY_SIZE // 2, + msg="$arrayElemAt should return middle in large array", + ), + ExpressionTestCase( + id="large_array_neg_middle", + doc={"arr": _LARGE_ARRAY, "idx": -(_LARGE_ARRAY_SIZE // 4)}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=_LARGE_ARRAY_SIZE - _LARGE_ARRAY_SIZE // 4, + msg="$arrayElemAt should return negative middle in large array", + ), +] + +ALL_TESTS = POSITIVE_INDEX_TESTS + NEGATIVE_INDEX_TESTS + DUPLICATE_VALUE_TESTS + LARGE_ARRAY_TESTS + +TEST_SUBSET_FOR_LITERAL: list[ExpressionTestCase] = [ + ExpressionTestCase( + "first_element_literal", + doc=None, + expression={"$arrayElemAt": [{"$literal": [1, 2, 3]}, 0]}, + expected=1, + msg="$arrayElemAt should return first element from literal array", + ), + ExpressionTestCase( + "last_via_neg1_literal", + doc=None, + expression={"$arrayElemAt": [{"$literal": [1, 2, 3]}, -1]}, + expected=3, + msg="$arrayElemAt should return last element via -1 from literal array", + ), + ExpressionTestCase( + "large_array_first_literal", + doc=None, + expression={"$arrayElemAt": [{"$literal": list(range(20_000))}, 0]}, + expected=0, + msg="$arrayElemAt should return first element from large literal array", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(TEST_SUBSET_FOR_LITERAL)) +def test_arrayElemAt_literal(collection, test): + """Test $arrayElemAt with literal values.""" + result = execute_expression(collection, test.expression) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_arrayElemAt_insert(collection, test): + """Test $arrayElemAt with values from inserted documents.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_arrayElemAt_element_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_arrayElemAt_element_types.py new file mode 100644 index 000000000..96083d2be --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_arrayElemAt_element_types.py @@ -0,0 +1,157 @@ +""" +Element type preservation tests for $arrayElemAt expression. + +Tests that $arrayElemAt correctly returns elements of all BSON types +including special float/Decimal128 values and boundary integers. +""" + +import math +from datetime import datetime, timezone + +import pytest +from bson import Binary, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_ONE_AND_HALF, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + INT32_MAX, + INT64_MAX, +) + +# Property [Element Types]: $arrayElemAt returns the element with its original BSON type. +ELEMENT_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="int64_element", + doc={"arr": [Int64(99)], "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=Int64(99), + msg="$arrayElemAt should return Int64 element", + ), + ExpressionTestCase( + id="decimal128_element", + doc={"arr": [DECIMAL128_ONE_AND_HALF], "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=DECIMAL128_ONE_AND_HALF, + msg="$arrayElemAt should return Decimal128 element", + ), + ExpressionTestCase( + id="datetime_element", + doc={"arr": [datetime(2024, 1, 1, tzinfo=timezone.utc)], "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=datetime(2024, 1, 1, tzinfo=timezone.utc), + msg="$arrayElemAt should return datetime element", + ), + ExpressionTestCase( + id="binary_element", + doc={"arr": [Binary(b"\x01\x02", 0)], "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=b"\x01\x02", + msg="$arrayElemAt should return binary element", + ), + ExpressionTestCase( + id="regex_element", + doc={"arr": [Regex("^abc", "i")], "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=Regex("^abc", "i"), + msg="$arrayElemAt should return regex element", + ), + ExpressionTestCase( + id="objectid_element", + doc={"arr": [ObjectId("000000000000000000000001")], "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=ObjectId("000000000000000000000001"), + msg="$arrayElemAt should return ObjectId element", + ), + ExpressionTestCase( + id="minkey_element", + doc={"arr": [MinKey(), 1], "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=MinKey(), + msg="$arrayElemAt should return MinKey element", + ), + ExpressionTestCase( + id="maxkey_element", + doc={"arr": [1, MaxKey()], "idx": 1}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=MaxKey(), + msg="$arrayElemAt should return MaxKey element", + ), + ExpressionTestCase( + id="timestamp_element", + doc={"arr": [Timestamp(0, 0)], "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=Timestamp(0, 0), + msg="$arrayElemAt should return Timestamp element", + ), + ExpressionTestCase( + id="float_nan_element", + doc={"arr": [FLOAT_NAN, 1], "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=pytest.approx(math.nan, nan_ok=True), + msg="$arrayElemAt should return NaN element", + ), + ExpressionTestCase( + id="float_infinity_element", + doc={"arr": [FLOAT_INFINITY, 1], "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=FLOAT_INFINITY, + msg="$arrayElemAt should return Infinity element", + ), + ExpressionTestCase( + id="float_neg_infinity_element", + doc={"arr": [FLOAT_NEGATIVE_INFINITY, 1], "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=FLOAT_NEGATIVE_INFINITY, + msg="$arrayElemAt should return -Infinity element", + ), + ExpressionTestCase( + id="decimal128_nan_element", + doc={"arr": [DECIMAL128_NAN, 1], "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=DECIMAL128_NAN, + msg="$arrayElemAt should return Decimal128 NaN element", + ), + ExpressionTestCase( + id="decimal128_infinity_element", + doc={"arr": [DECIMAL128_INFINITY, 1], "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=DECIMAL128_INFINITY, + msg="$arrayElemAt should return Decimal128 Infinity element", + ), + ExpressionTestCase( + id="decimal128_neg_infinity_element", + doc={"arr": [DECIMAL128_NEGATIVE_INFINITY, 1], "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=DECIMAL128_NEGATIVE_INFINITY, + msg="$arrayElemAt should return Decimal128 -Infinity element", + ), + ExpressionTestCase( + id="int32_max_element", + doc={"arr": [INT32_MAX, 0], "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=INT32_MAX, + msg="$arrayElemAt should return INT32_MAX element", + ), + ExpressionTestCase( + id="int64_max_element", + doc={"arr": [INT64_MAX, 0], "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=INT64_MAX, + msg="$arrayElemAt should return INT64_MAX element", + ), + ExpressionTestCase( + id="mixed_special_last", + doc={"arr": [INT32_MAX, FLOAT_INFINITY, DECIMAL128_NAN], "idx": 2}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=DECIMAL128_NAN, + msg="$arrayElemAt should return element from mixed special values array", + ), +] diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_arrayElemAt_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_arrayElemAt_errors.py new file mode 100644 index 000000000..344934c33 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_arrayElemAt_errors.py @@ -0,0 +1,393 @@ +""" +Error tests for $arrayElemAt expression. + +Tests non-array first argument, non-numeric index, non-integral numeric index, +and wrong arity errors. +""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + ARRAY_ELEM_AT_ARRAY_TYPE_ERROR, + ARRAY_ELEM_AT_INDEX_NOT_INTEGRAL_ERROR, + ARRAY_ELEM_AT_INDEX_TYPE_ERROR, + EXPRESSION_TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.test_constants import ( + DECIMAL128_HALF, + DECIMAL128_INFINITY, + DECIMAL128_INT64_OVERFLOW, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + INT32_OVERFLOW, + INT64_MAX, + INT64_MIN, +) + +# Property [Array Type Strictness]: $arrayElemAt rejects a non-array first argument. +ARRAY_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="string_as_array", + doc={"arr": "hello", "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_ARRAY_TYPE_ERROR, + msg="$arrayElemAt should reject string as array", + ), + ExpressionTestCase( + id="int_as_array", + doc={"arr": 42, "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_ARRAY_TYPE_ERROR, + msg="$arrayElemAt should reject int as array", + ), + ExpressionTestCase( + id="bool_true_as_array", + doc={"arr": True, "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_ARRAY_TYPE_ERROR, + msg="$arrayElemAt should reject bool true as array", + ), + ExpressionTestCase( + id="bool_false_as_array", + doc={"arr": False, "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_ARRAY_TYPE_ERROR, + msg="$arrayElemAt should reject bool false as array", + ), + ExpressionTestCase( + id="object_as_array", + doc={"arr": {"a": 1}, "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_ARRAY_TYPE_ERROR, + msg="$arrayElemAt should reject object as array", + ), + ExpressionTestCase( + id="double_as_array", + doc={"arr": 3.14, "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_ARRAY_TYPE_ERROR, + msg="$arrayElemAt should reject double as array", + ), + ExpressionTestCase( + id="decimal128_as_array", + doc={"arr": Decimal128("1"), "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_ARRAY_TYPE_ERROR, + msg="$arrayElemAt should reject decimal128 as array", + ), + ExpressionTestCase( + id="int64_as_array", + doc={"arr": Int64(1), "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_ARRAY_TYPE_ERROR, + msg="$arrayElemAt should reject int64 as array", + ), + ExpressionTestCase( + id="binary_as_array", + doc={"arr": Binary(b"x", 0), "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_ARRAY_TYPE_ERROR, + msg="$arrayElemAt should reject binary as array", + ), + ExpressionTestCase( + id="datetime_as_array", + doc={"arr": datetime(2024, 1, 1, tzinfo=timezone.utc), "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_ARRAY_TYPE_ERROR, + msg="$arrayElemAt should reject datetime as array", + ), + ExpressionTestCase( + id="objectid_as_array", + doc={"arr": ObjectId(), "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_ARRAY_TYPE_ERROR, + msg="$arrayElemAt should reject objectid as array", + ), + ExpressionTestCase( + id="regex_as_array", + doc={"arr": Regex("x"), "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_ARRAY_TYPE_ERROR, + msg="$arrayElemAt should reject regex as array", + ), + ExpressionTestCase( + id="maxkey_as_array", + doc={"arr": MaxKey(), "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_ARRAY_TYPE_ERROR, + msg="$arrayElemAt should reject maxkey as array", + ), + ExpressionTestCase( + id="minkey_as_array", + doc={"arr": MinKey(), "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_ARRAY_TYPE_ERROR, + msg="$arrayElemAt should reject minkey as array", + ), + ExpressionTestCase( + id="timestamp_as_array", + doc={"arr": Timestamp(0, 0), "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_ARRAY_TYPE_ERROR, + msg="$arrayElemAt should reject timestamp as array", + ), + ExpressionTestCase( + id="nan_as_array", + doc={"arr": FLOAT_NAN, "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_ARRAY_TYPE_ERROR, + msg="$arrayElemAt should reject NaN as array", + ), + ExpressionTestCase( + id="inf_as_array", + doc={"arr": FLOAT_INFINITY, "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_ARRAY_TYPE_ERROR, + msg="$arrayElemAt should reject Infinity as array", + ), + ExpressionTestCase( + id="decimal128_nan_as_array", + doc={"arr": DECIMAL128_NAN, "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_ARRAY_TYPE_ERROR, + msg="$arrayElemAt should reject Decimal128 NaN as array", + ), + ExpressionTestCase( + id="decimal128_inf_as_array", + doc={"arr": DECIMAL128_INFINITY, "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_ARRAY_TYPE_ERROR, + msg="$arrayElemAt should reject Decimal128 Infinity as array", + ), +] + +# Property [Index Type Strictness]: $arrayElemAt rejects a non-numeric index. +INDEX_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="string_index", + doc={"arr": [1, 2], "idx": "0"}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_INDEX_TYPE_ERROR, + msg="$arrayElemAt should reject string index", + ), + ExpressionTestCase( + id="bool_true_index", + doc={"arr": [1, 2], "idx": True}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_INDEX_TYPE_ERROR, + msg="$arrayElemAt should reject bool true index", + ), + ExpressionTestCase( + id="bool_false_index", + doc={"arr": [1, 2], "idx": False}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_INDEX_TYPE_ERROR, + msg="$arrayElemAt should reject bool false index", + ), + ExpressionTestCase( + id="array_index", + doc={"arr": [1, 2], "idx": [0]}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_INDEX_TYPE_ERROR, + msg="$arrayElemAt should reject array index", + ), + ExpressionTestCase( + id="object_index", + doc={"arr": [1, 2], "idx": {"a": 0}}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_INDEX_TYPE_ERROR, + msg="$arrayElemAt should reject object index", + ), + ExpressionTestCase( + id="objectid_index", + doc={"arr": [1, 2], "idx": ObjectId()}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_INDEX_TYPE_ERROR, + msg="$arrayElemAt should reject objectid index", + ), + ExpressionTestCase( + id="binary_index", + doc={"arr": [1, 2], "idx": Binary(b"\x01", 0)}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_INDEX_TYPE_ERROR, + msg="$arrayElemAt should reject binary index", + ), + ExpressionTestCase( + id="timestamp_index", + doc={"arr": [1, 2], "idx": Timestamp(0, 0)}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_INDEX_TYPE_ERROR, + msg="$arrayElemAt should reject timestamp index", + ), + ExpressionTestCase( + id="datetime_index", + doc={"arr": [1, 2], "idx": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_INDEX_TYPE_ERROR, + msg="$arrayElemAt should reject datetime index", + ), + ExpressionTestCase( + id="maxkey_index", + doc={"arr": [1, 2], "idx": MaxKey()}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_INDEX_TYPE_ERROR, + msg="$arrayElemAt should reject maxkey index", + ), + ExpressionTestCase( + id="minkey_index", + doc={"arr": [1, 2], "idx": MinKey()}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_INDEX_TYPE_ERROR, + msg="$arrayElemAt should reject minkey index", + ), + ExpressionTestCase( + id="regex_index", + doc={"arr": [1, 2], "idx": Regex("x")}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_INDEX_TYPE_ERROR, + msg="$arrayElemAt should reject regex index", + ), +] + +# Property [Integral Index]: $arrayElemAt rejects a non-integral or out-of-range numeric index. +NON_INTEGRAL_INDEX_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="double_fractional_index", + doc={"arr": [1, 2, 3], "idx": 1.5}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_INDEX_NOT_INTEGRAL_ERROR, + msg="$arrayElemAt should reject fractional double index", + ), + ExpressionTestCase( + id="decimal128_fractional_index", + doc={"arr": [1, 2, 3], "idx": DECIMAL128_HALF}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_INDEX_NOT_INTEGRAL_ERROR, + msg="$arrayElemAt should reject fractional decimal128 index", + ), + ExpressionTestCase( + id="double_nan_index", + doc={"arr": [1, 2, 3], "idx": FLOAT_NAN}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_INDEX_NOT_INTEGRAL_ERROR, + msg="$arrayElemAt should reject NaN index", + ), + ExpressionTestCase( + id="double_inf_index", + doc={"arr": [1, 2, 3], "idx": FLOAT_INFINITY}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_INDEX_NOT_INTEGRAL_ERROR, + msg="$arrayElemAt should reject infinity index", + ), + ExpressionTestCase( + id="double_neg_inf_index", + doc={"arr": [1, 2, 3], "idx": FLOAT_NEGATIVE_INFINITY}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_INDEX_NOT_INTEGRAL_ERROR, + msg="$arrayElemAt should reject -infinity index", + ), + ExpressionTestCase( + id="decimal128_nan_index", + doc={"arr": [1, 2, 3], "idx": DECIMAL128_NAN}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_INDEX_NOT_INTEGRAL_ERROR, + msg="$arrayElemAt should reject decimal128 NaN index", + ), + ExpressionTestCase( + id="decimal128_inf_index", + doc={"arr": [1, 2, 3], "idx": DECIMAL128_INFINITY}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_INDEX_NOT_INTEGRAL_ERROR, + msg="$arrayElemAt should reject decimal128 infinity index", + ), + ExpressionTestCase( + id="decimal128_neg_inf_index", + doc={"arr": [1, 2, 3], "idx": DECIMAL128_NEGATIVE_INFINITY}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_INDEX_NOT_INTEGRAL_ERROR, + msg="$arrayElemAt should reject decimal128 -infinity index", + ), + ExpressionTestCase( + id="int64_max_index", + doc={"arr": [1, 2, 3], "idx": INT64_MAX}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_INDEX_NOT_INTEGRAL_ERROR, + msg="$arrayElemAt should reject INT64_MAX index", + ), + ExpressionTestCase( + id="int64_min_index", + doc={"arr": [1, 2, 3], "idx": INT64_MIN}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_INDEX_NOT_INTEGRAL_ERROR, + msg="$arrayElemAt should reject INT64_MIN index", + ), + ExpressionTestCase( + id="large_double_index", + doc={"arr": [1, 2, 3], "idx": 1.0e18}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_INDEX_NOT_INTEGRAL_ERROR, + msg="$arrayElemAt should reject large double index", + ), + ExpressionTestCase( + id="large_neg_double_index", + doc={"arr": [1, 2, 3], "idx": -1.0e18}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_INDEX_NOT_INTEGRAL_ERROR, + msg="$arrayElemAt should reject large negative double index", + ), + ExpressionTestCase( + id="decimal128_beyond_int32", + doc={"arr": [1, 2, 3], "idx": Decimal128(str(INT32_OVERFLOW))}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_INDEX_NOT_INTEGRAL_ERROR, + msg="$arrayElemAt should reject decimal128 beyond int32", + ), + ExpressionTestCase( + id="decimal128_huge", + doc={"arr": [1, 2, 3], "idx": DECIMAL128_INT64_OVERFLOW}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_INDEX_NOT_INTEGRAL_ERROR, + msg="$arrayElemAt should reject huge decimal128 index", + ), +] + +ALL_TESTS = ARRAY_TYPE_ERROR_TESTS + INDEX_TYPE_ERROR_TESTS + NON_INTEGRAL_INDEX_TESTS + +# Property [Arity]: $arrayElemAt requires exactly two arguments. +ARITY_ERROR_TESTS = [ + pytest.param({"$arrayElemAt": [[1, 2, 3]]}, id="one_arg"), + pytest.param({"$arrayElemAt": [[1, 2, 3], 0, 1]}, id="three_args"), + pytest.param({"$arrayElemAt": []}, id="zero_args"), + pytest.param({"$arrayElemAt": [[[1, 2, 3], 0]]}, id="nested_pair_not_flattened_to_two_args"), +] + + +@pytest.mark.parametrize("expr", ARITY_ERROR_TESTS) +def test_arrayElemAt_syntax_error(collection, expr): + """Test $arrayElemAt errors with wrong number of arguments.""" + result = execute_expression(collection, expr) + assert_expression_result(result, error_code=EXPRESSION_TYPE_MISMATCH_ERROR) + + +# Property [Index Type Strictness]: $arrayElemAt rejects a field path that resolves to an +# array as the index argument. +def test_arrayElemAt_composite_array_as_index(collection): + """Test $arrayElemAt rejects a composite array from $x.y as the index argument.""" + result = execute_expression_with_insert( + collection, {"$arrayElemAt": [[10, 20, 30], "$x.y"]}, {"x": [{"y": 0}, {"y": 1}]} + ) + assert_expression_result(result, error_code=ARRAY_ELEM_AT_INDEX_TYPE_ERROR) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_arrayElemAt_expressions.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_arrayElemAt_expressions.py new file mode 100644 index 000000000..495abc5bb --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_arrayElemAt_expressions.py @@ -0,0 +1,146 @@ +""" +Expression and field path tests for $arrayElemAt expression. + +Tests nested expressions, field path lookups, composite paths, +and path through array of objects. +""" + +import pytest +from bson import Decimal128 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.test_constants import DECIMAL128_ZERO + + +# Nested expressions +@pytest.mark.parametrize( + "expression,expected", + [ + # 2D array access: arr[1][0] + ({"$arrayElemAt": [{"$arrayElemAt": [[[10, 20], [30, 40]], 1]}, 0]}, 30), + # 3D array access: arr[1][0][1] + ( + { + "$arrayElemAt": [ + { + "$arrayElemAt": [ + {"$arrayElemAt": [[[[1, 2], [3, 4]], [[5, 6], [7, 8]]], 1]}, + 0, + ] + }, + 1, + ] + }, + 6, + ), + # 4D array access: arr[1][1][0][1] + ( + { + "$arrayElemAt": [ + { + "$arrayElemAt": [ + { + "$arrayElemAt": [ + { + "$arrayElemAt": [ + [ + [[[1, 2], [3, 4]], [[5, 6], [7, 8]]], + [[[9, 10], [11, 12]], [[13, 14], [15, 16]]], + ], + 1, + ] + }, + 1, + ] + }, + 0, + ] + }, + 1, + ] + }, + 14, + ), + ], + ids=["nested_2d_access", "nested_3d_access", "nested_4d_access"], +) +def test_arrayElemAt_nested_expression(collection, expression, expected): + """Test $arrayElemAt composed with other expressions.""" + result = execute_expression(collection, expression) + assert_expression_result(result, expected=expected) + + +# Field path lookups: $arrayElemAt with array and/or index arguments resolved from inserted +# document fields. These all share one execution path, so they are parametrized into one test. +@pytest.mark.parametrize( + "document,array_ref,idx,expected", + [ + ({"a": {"b": [10, 20, 30]}}, "$a.b", 1, 20), + ({"a": {"missing": 1}}, "$a.nonexistent", 0, None), + ({"a": {"b": {"c": [5, 6, 7]}}}, "$a.b.c", -1, 7), + # field path traverses an array of objects -> collapses to [10, 20] + ({"a": [{"b": 10}, {"b": 20}]}, "$a.b", 0, 10), + # literal array with a field-path index + ({"a": {"b": 1}}, [10, 20, 30], "$a.b", 20), + # array argument resolved from an array of objects + ({"x": [{"y": 10}, {"y": 20}, {"y": 30}]}, "$x.y", 1, 20), + # numeric path component ".0" addresses an object key "0", not an array position + ({"a": {"0": {"b": [10, 20, 30]}}}, "$a.0.b", 1, 20), + # Decimal128 index resolved alongside a composite array path + ({"a": [{"b": 1}, {"b": 2}, {"b": 3}]}, "$a.b", DECIMAL128_ZERO, 1), + ({"a": [{"b": 1}, {"b": 2}, {"b": 3}]}, "$a.b", Decimal128("-1"), 3), + # first argument is an array expression whose elements are field references + ({"x": 10, "y": 20}, ["$x", "$y"], 0, 10), + ({"x": 10, "y": 20}, ["$x", "$y"], 1, 20), + ({"x": 10, "y": 20}, ["$x", "$y"], -1, 20), + ], + ids=[ + "nested_field_path", + "nonexistent_field_null", + "deeply_nested_field", + "path_through_array_of_objects", + "composite_path_for_index", + "composite_array_as_array", + "object_numeric_key_path", + "composite_decimal128_pos", + "composite_decimal128_neg", + "array_expr_first", + "array_expr_second", + "array_expr_negative", + ], +) +def test_arrayElemAt_field_lookup(collection, document, array_ref, idx, expected): + """Test $arrayElemAt with array/index arguments resolved from inserted document fields.""" + result = execute_expression_with_insert( + collection, {"$arrayElemAt": [array_ref, idx]}, document + ) + assert_expression_result(result, expected=expected) + + +# Field-path lookups that resolve to a missing result: an out-of-bounds Decimal128 index, or a +# numeric path component that does not positionally index an array. Same execution path -> one test. +@pytest.mark.parametrize( + "document,array_ref,idx", + [ + ({"a": [{"b": 1}, {"b": 2}, {"b": 3}]}, "$a.b", Decimal128("4")), + ({"a": [{"b": 1}, {"b": 2}, {"b": 3}]}, "$a.b", Decimal128("-4")), + # "$a.0" does not positionally index an array in expression context -> missing + ({"a": [[1, 2], [3, 4]]}, "$a.0", 0), + ], + ids=[ + "composite_decimal128_oob_pos", + "composite_decimal128_oob_neg", + "numeric_path_component_not_array_index", + ], +) +def test_arrayElemAt_field_lookup_missing(collection, document, array_ref, idx): + """Test $arrayElemAt returns a missing result for out-of-bounds field-path lookups.""" + result = execute_expression_with_insert( + collection, {"$arrayElemAt": [array_ref, idx]}, document + ) + assertSuccess(result, [{}]) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_arrayElemAt_null_missing.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_arrayElemAt_null_missing.py new file mode 100644 index 000000000..0d1f154cf --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_arrayElemAt_null_missing.py @@ -0,0 +1,60 @@ +""" +Null and missing field behavior tests for $arrayElemAt expression. + +Tests null propagation and missing field handling for array and index arguments. +""" + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.framework.test_constants import MISSING + +# Property [Null Propagation]: $arrayElemAt returns null when the array or index argument is null. +NULL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="null_array", + doc={"arr": None, "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=None, + msg="$arrayElemAt should return null for null array", + ), + ExpressionTestCase( + id="null_array_neg_idx", + doc={"arr": None, "idx": -1}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=None, + msg="$arrayElemAt should return null for null array with negative index", + ), + ExpressionTestCase( + id="null_index", + doc={"arr": [1, 2], "idx": None}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=None, + msg="$arrayElemAt should return null for null index", + ), + ExpressionTestCase( + id="both_null", + doc={"arr": None, "idx": None}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=None, + msg="$arrayElemAt should return null when both null", + ), +] + +# Property [Missing Propagation]: $arrayElemAt returns null when the array or index is missing. +LITERAL_ONLY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="missing_array", + doc={"arr": MISSING, "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=None, + msg="$arrayElemAt should return null for missing array", + ), + ExpressionTestCase( + id="missing_index", + doc={"arr": [1, 2, 3], "idx": MISSING}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=None, + msg="$arrayElemAt should return null for missing index", + ), +] diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_arrayElemAt_numeric_index.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_arrayElemAt_numeric_index.py new file mode 100644 index 000000000..7b88f9208 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_arrayElemAt_numeric_index.py @@ -0,0 +1,156 @@ +""" +Numeric index type tests for $arrayElemAt expression. + +Tests various numeric types (Int64, double, Decimal128) and edge cases +like negative zero and Decimal128 scientific notation as index values. +""" + +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.framework.test_constants import ( + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_TRAILING_ZERO, + DECIMAL128_ZERO, + DOUBLE_NEGATIVE_ZERO, + INT64_ZERO, +) + +# Property [Numeric Index Types]: $arrayElemAt accepts int32, int64, and integral double indexes. +NUMERIC_INDEX_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="int64_zero_index", + doc={"arr": [10, 20, 30], "idx": INT64_ZERO}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=10, + msg="$arrayElemAt should accept Int64 zero index", + ), + ExpressionTestCase( + id="int64_index", + doc={"arr": [10, 20, 30], "idx": Int64(1)}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=20, + msg="$arrayElemAt should accept Int64 index", + ), + ExpressionTestCase( + id="double_integral_index", + doc={"arr": [10, 20, 30], "idx": 2.0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=30, + msg="$arrayElemAt should accept integral double index", + ), + ExpressionTestCase( + id="decimal128_integral_index", + doc={"arr": [10, 20, 30], "idx": DECIMAL128_ZERO}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=10, + msg="$arrayElemAt should accept Decimal128 index", + ), + ExpressionTestCase( + id="int64_negative_index", + doc={"arr": [10, 20, 30], "idx": Int64(-1)}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=30, + msg="$arrayElemAt should accept negative Int64 index", + ), + ExpressionTestCase( + id="double_negative_integral", + doc={"arr": [10, 20, 30], "idx": -2.0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=20, + msg="$arrayElemAt should accept negative integral double index", + ), + ExpressionTestCase( + id="negative_zero_index", + doc={"arr": [10, 20, 30], "idx": DOUBLE_NEGATIVE_ZERO}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=10, + msg="$arrayElemAt should treat -0.0 as index 0", + ), + ExpressionTestCase( + id="decimal128_negative_zero_index", + doc={"arr": [10, 20, 30], "idx": DECIMAL128_NEGATIVE_ZERO}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=10, + msg="$arrayElemAt should treat decimal128 -0 as index 0", + ), + ExpressionTestCase( + id="decimal128_trailing_zero", + doc={"arr": [10, 20, 30], "idx": DECIMAL128_TRAILING_ZERO}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=20, + msg="$arrayElemAt should accept decimal128 with trailing zero", + ), + ExpressionTestCase( + id="decimal128_subnormal_zero", + doc={"arr": [10, 20, 30], "idx": Decimal128("0E-6176")}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=10, + msg="$arrayElemAt should accept decimal128 subnormal zero", + ), + ExpressionTestCase( + id="decimal128_20E_neg1", + doc={"arr": [10, 20, 30], "idx": Decimal128("20E-1")}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=30, + msg="$arrayElemAt should accept decimal128 20E-1 as index 2", + ), + ExpressionTestCase( + id="decimal128_0_2E1", + doc={"arr": [10, 20, 30], "idx": Decimal128("0.2E1")}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=30, + msg="$arrayElemAt should accept decimal128 0.2E1 as index 2", + ), + ExpressionTestCase( + id="decimal128_2E0", + doc={"arr": [10, 20, 30], "idx": Decimal128("2E0")}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=30, + msg="$arrayElemAt should accept decimal128 2E0 as index 2", + ), + ExpressionTestCase( + id="decimal128_10E_neg1", + doc={"arr": [10, 20, 30], "idx": Decimal128("10E-1")}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=20, + msg="$arrayElemAt should accept decimal128 10E-1 as index 1", + ), + ExpressionTestCase( + id="decimal128_negative_integral_index", + doc={"arr": [10, 20, 30], "idx": Decimal128("-1")}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=30, + msg="$arrayElemAt should accept negative Decimal128 integral index", + ), + ExpressionTestCase( + id="decimal128_neg_10E_neg1", + doc={"arr": [10, 20, 30], "idx": Decimal128("-10E-1")}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=30, + msg="$arrayElemAt should accept decimal128 -10E-1 as index -1", + ), + ExpressionTestCase( + id="decimal128_0E_pos3", + doc={"arr": [10, 20, 30], "idx": Decimal128("0E+3")}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=10, + msg="$arrayElemAt should accept decimal128 0E+3 as index 0", + ), + ExpressionTestCase( + id="decimal128_0E_neg3", + doc={"arr": [10, 20, 30], "idx": Decimal128("0E-3")}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=10, + msg="$arrayElemAt should accept decimal128 0E-3 as index 0", + ), + ExpressionTestCase( + id="decimal128_1_00000", + doc={"arr": [10, 20, 30], "idx": Decimal128("1.00000")}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=20, + msg="$arrayElemAt should accept decimal128 1.00000 as index 1", + ), +] diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_arrayElemAt_out_of_bounds.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_arrayElemAt_out_of_bounds.py new file mode 100644 index 000000000..674973526 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_arrayElemAt_out_of_bounds.py @@ -0,0 +1,101 @@ +""" +Out-of-bounds index tests for $arrayElemAt expression. + +Tests that $arrayElemAt returns no result (missing) when the index +exceeds array bounds in either direction. +""" + +from bson import Decimal128 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.framework.test_constants import INT32_MAX, INT32_MIN + +# Property [Out Of Bounds]: $arrayElemAt returns no value when the index is out of bounds. +OUT_OF_BOUNDS_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="positive_oob", + doc={"arr": [1, 2, 3], "idx": 15}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=[{}], + msg="$arrayElemAt should return no result for positive OOB", + ), + ExpressionTestCase( + id="positive_oob_by_one", + doc={"arr": [1, 2, 3], "idx": 3}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=[{}], + msg="$arrayElemAt should return no result for OOB by one", + ), + ExpressionTestCase( + id="negative_oob", + doc={"arr": [1, 2, 3], "idx": -4}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=[{}], + msg="$arrayElemAt should return no result for negative OOB", + ), + ExpressionTestCase( + id="negative_oob_large", + doc={"arr": [1, 2, 3], "idx": -100}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=[{}], + msg="$arrayElemAt should return no result for large negative OOB", + ), + ExpressionTestCase( + id="empty_array_idx_0", + doc={"arr": [], "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=[{}], + msg="$arrayElemAt should return no result for empty array idx 0", + ), + ExpressionTestCase( + id="empty_array_neg1", + doc={"arr": [], "idx": -1}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=[{}], + msg="$arrayElemAt should return no result for empty array idx -1", + ), + ExpressionTestCase( + id="int32_max_oob", + doc={"arr": [1, 2, 3], "idx": INT32_MAX}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=[{}], + msg="$arrayElemAt should return no result for INT32_MAX index", + ), + ExpressionTestCase( + id="int32_min_oob", + doc={"arr": [1, 2, 3], "idx": INT32_MIN}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=[{}], + msg="$arrayElemAt should return no result for INT32_MIN index", + ), + ExpressionTestCase( + id="single_element_oob_pos", + doc={"arr": [42], "idx": 1}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=[{}], + msg="$arrayElemAt should return no result for single element OOB positive", + ), + ExpressionTestCase( + id="single_element_oob_neg", + doc={"arr": [42], "idx": -2}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=[{}], + msg="$arrayElemAt should return no result for single element OOB negative", + ), + ExpressionTestCase( + id="decimal128_oob_pos", + doc={"arr": [1, 2, 3], "idx": Decimal128("15")}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=[{}], + msg="$arrayElemAt should return no result for Decimal128 positive OOB", + ), + ExpressionTestCase( + id="decimal128_oob_neg", + doc={"arr": [1, 2, 3], "idx": Decimal128("-100")}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=[{}], + msg="$arrayElemAt should return no result for Decimal128 negative OOB", + ), +] diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_smoke_expression_arrayElemAt.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_smoke_arrayElemAt.py similarity index 88% rename from documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_smoke_expression_arrayElemAt.py rename to documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_smoke_arrayElemAt.py index 1f287e081..0699dd680 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_smoke_expression_arrayElemAt.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_smoke_arrayElemAt.py @@ -26,4 +26,4 @@ def test_smoke_expression_arrayElemAt(collection): ) expected = [{"_id": 1, "element": 20}, {"_id": 2, "element": 15}] - assertSuccess(result, expected, msg="Should support $arrayElemAt expression") + assertSuccess(result, expected, "Should support $arrayElemAt expression", ignore_doc_order=True) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayToObject/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayToObject/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayToObject/test_arrayToObject_bson_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayToObject/test_arrayToObject_bson_types.py new file mode 100644 index 000000000..4d2095d87 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayToObject/test_arrayToObject_bson_types.py @@ -0,0 +1,463 @@ +""" +BSON type tests for $arrayToObject expression. + +Tests that various BSON value types are preserved when converting +arrays to objects, including special numeric values, boundary values, +UUID binary, nested BSON values, and numeric type equivalence, +across both k/v and pair input forms. +""" + +import math +from datetime import datetime, timezone +from uuid import UUID + +import pytest +from bson import Binary, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, +) +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_MAX, + DECIMAL128_MIN, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ONE_AND_HALF, + DECIMAL128_TWO_AND_HALF, + DOUBLE_NEGATIVE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + INT32_MAX, + INT32_MIN, + INT64_MAX, + INT64_MIN, +) + +# Property [Value Types K/V]: $arrayToObject preserves each value's BSON type in k/v form. +BSON_KV_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="kv_int64", + doc={"arr": [{"k": "a", "v": Int64(99)}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": Int64(99)}, + msg="$arrayToObject should preserve Int64 value", + ), + ExpressionTestCase( + id="kv_decimal128", + doc={"arr": [{"k": "a", "v": Decimal128("3.14")}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": Decimal128("3.14")}, + msg="$arrayToObject should preserve Decimal128 value", + ), + ExpressionTestCase( + id="kv_datetime", + doc={"arr": [{"k": "a", "v": datetime(2024, 1, 1, tzinfo=timezone.utc)}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + msg="$arrayToObject should preserve datetime value", + ), + ExpressionTestCase( + id="kv_objectid", + doc={"arr": [{"k": "a", "v": ObjectId("000000000000000000000001")}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": ObjectId("000000000000000000000001")}, + msg="$arrayToObject should preserve ObjectId value", + ), + ExpressionTestCase( + id="kv_bool_false", + doc={"arr": [{"k": "a", "v": False}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": False}, + msg="$arrayToObject should preserve false value", + ), + ExpressionTestCase( + id="kv_bool_true", + doc={"arr": [{"k": "a", "v": True}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": True}, + msg="$arrayToObject should preserve true value", + ), + ExpressionTestCase( + id="kv_null", + doc={"arr": [{"k": "a", "v": None}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": None}, + msg="$arrayToObject should preserve null value", + ), + ExpressionTestCase( + id="kv_regex", + doc={"arr": [{"k": "a", "v": Regex("^abc", "i")}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": Regex("^abc", "i")}, + msg="$arrayToObject should preserve regex value", + ), + ExpressionTestCase( + id="kv_minkey", + doc={"arr": [{"k": "a", "v": MinKey()}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": MinKey()}, + msg="$arrayToObject should preserve MinKey value", + ), + ExpressionTestCase( + id="kv_maxkey", + doc={"arr": [{"k": "a", "v": MaxKey()}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": MaxKey()}, + msg="$arrayToObject should preserve MaxKey value", + ), + ExpressionTestCase( + id="kv_binary", + doc={"arr": [{"k": "a", "v": Binary(b"\x01\x02\x03", 0)}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": b"\x01\x02\x03"}, + msg="$arrayToObject should preserve Binary value", + ), + ExpressionTestCase( + id="kv_timestamp", + doc={"arr": [{"k": "a", "v": Timestamp(1234567890, 1)}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": Timestamp(1234567890, 1)}, + msg="$arrayToObject should preserve Timestamp value", + ), + ExpressionTestCase( + id="kv_uuid", + doc={ + "arr": [{"k": "a", "v": Binary.from_uuid(UUID("01234567-89ab-cdef-fedc-ba9876543210"))}] + }, + expression={"$arrayToObject": "$arr"}, + expected={"a": Binary.from_uuid(UUID("01234567-89ab-cdef-fedc-ba9876543210"))}, + msg="$arrayToObject should preserve UUID binary value", + ), +] + +# Property [Value Types Pair]: $arrayToObject preserves each value's BSON type in pair form. +BSON_PAIR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="pair_int64", + doc={"arr": [["a", Int64(99)]]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": Int64(99)}, + msg="$arrayToObject should preserve Int64 value (pair form)", + ), + ExpressionTestCase( + id="pair_decimal128", + doc={"arr": [["a", Decimal128("3.14")]]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": Decimal128("3.14")}, + msg="$arrayToObject should preserve Decimal128 value (pair form)", + ), + ExpressionTestCase( + id="pair_datetime", + doc={"arr": [["a", datetime(2024, 1, 1, tzinfo=timezone.utc)]]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + msg="$arrayToObject should preserve datetime value (pair form)", + ), + ExpressionTestCase( + id="pair_objectid", + doc={"arr": [["a", ObjectId("000000000000000000000001")]]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": ObjectId("000000000000000000000001")}, + msg="$arrayToObject should preserve ObjectId value (pair form)", + ), + ExpressionTestCase( + id="pair_binary", + doc={"arr": [["a", Binary(b"\x01\x02\x03", 0)]]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": b"\x01\x02\x03"}, + msg="$arrayToObject should preserve Binary value (pair form)", + ), + ExpressionTestCase( + id="pair_timestamp", + doc={"arr": [["a", Timestamp(1234567890, 1)]]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": Timestamp(1234567890, 1)}, + msg="$arrayToObject should preserve Timestamp value (pair form)", + ), + ExpressionTestCase( + id="pair_regex", + doc={"arr": [["a", Regex("^abc", "i")]]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": Regex("^abc", "i")}, + msg="$arrayToObject should preserve regex value (pair form)", + ), + ExpressionTestCase( + id="pair_minkey", + doc={"arr": [["a", MinKey()]]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": MinKey()}, + msg="$arrayToObject should preserve MinKey value (pair form)", + ), + ExpressionTestCase( + id="pair_maxkey", + doc={"arr": [["a", MaxKey()]]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": MaxKey()}, + msg="$arrayToObject should preserve MaxKey value (pair form)", + ), + ExpressionTestCase( + id="pair_uuid", + doc={"arr": [["a", Binary.from_uuid(UUID("01234567-89ab-cdef-fedc-ba9876543210"))]]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": Binary.from_uuid(UUID("01234567-89ab-cdef-fedc-ba9876543210"))}, + msg="$arrayToObject should preserve UUID binary value (pair form)", + ), +] + +# Property [Special Numerics]: $arrayToObject preserves NaN, Infinity, and negative zero. +SPECIAL_NUMERIC_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="value_infinity", + doc={"arr": [{"k": "a", "v": FLOAT_INFINITY}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": FLOAT_INFINITY}, + msg="$arrayToObject should preserve Infinity value", + ), + ExpressionTestCase( + id="value_neg_infinity", + doc={"arr": [{"k": "a", "v": FLOAT_NEGATIVE_INFINITY}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": FLOAT_NEGATIVE_INFINITY}, + msg="$arrayToObject should preserve -Infinity value", + ), + ExpressionTestCase( + id="value_neg_zero", + doc={"arr": [{"k": "a", "v": DOUBLE_NEGATIVE_ZERO}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": DOUBLE_NEGATIVE_ZERO}, + msg="$arrayToObject should preserve negative zero value", + ), + ExpressionTestCase( + id="value_decimal128_nan", + doc={"arr": [{"k": "a", "v": DECIMAL128_NAN}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": DECIMAL128_NAN}, + msg="$arrayToObject should preserve Decimal128 NaN value", + ), + ExpressionTestCase( + id="value_decimal128_infinity", + doc={"arr": [{"k": "a", "v": DECIMAL128_INFINITY}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": DECIMAL128_INFINITY}, + msg="$arrayToObject should preserve Decimal128 Infinity value", + ), + ExpressionTestCase( + id="value_decimal128_neg_infinity", + doc={"arr": [{"k": "a", "v": DECIMAL128_NEGATIVE_INFINITY}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": DECIMAL128_NEGATIVE_INFINITY}, + msg="$arrayToObject should preserve Decimal128 -Infinity value", + ), + ExpressionTestCase( + id="value_decimal128_neg_zero", + doc={"arr": [{"k": "a", "v": DECIMAL128_NEGATIVE_ZERO}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": DECIMAL128_NEGATIVE_ZERO}, + msg="$arrayToObject should preserve Decimal128 -0 value", + ), + ExpressionTestCase( + id="value_decimal128_high_precision", + doc={"arr": [{"k": "a", "v": Decimal128("1.234567890123456789012345678901234")}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": Decimal128("1.234567890123456789012345678901234")}, + msg="$arrayToObject should preserve full Decimal128 precision", + ), + ExpressionTestCase( + id="value_decimal128_zero_exponent", + doc={"arr": [{"k": "a", "v": Decimal128("0E+10")}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": Decimal128("0E+10")}, + msg="$arrayToObject should preserve Decimal128 exponent notation", + ), + ExpressionTestCase( + id="value_decimal128_trailing_zeros", + doc={"arr": [{"k": "a", "v": Decimal128("1.00000")}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": Decimal128("1.00000")}, + msg="$arrayToObject should preserve Decimal128 trailing zeros", + ), + ExpressionTestCase( + id="value_decimal128_subnormal_zero", + doc={"arr": [{"k": "a", "v": Decimal128("0E-6176")}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": Decimal128("0E-6176")}, + msg="$arrayToObject should preserve Decimal128 subnormal zero", + ), +] + +# Property [Numeric Boundaries]: $arrayToObject preserves numeric boundary values. +BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="value_int32_max", + doc={"arr": [{"k": "a", "v": INT32_MAX}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": INT32_MAX}, + msg="$arrayToObject should preserve INT32_MAX value", + ), + ExpressionTestCase( + id="value_int32_min", + doc={"arr": [{"k": "a", "v": INT32_MIN}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": INT32_MIN}, + msg="$arrayToObject should preserve INT32_MIN value", + ), + ExpressionTestCase( + id="value_int64_max", + doc={"arr": [{"k": "a", "v": INT64_MAX}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": INT64_MAX}, + msg="$arrayToObject should preserve INT64_MAX value", + ), + ExpressionTestCase( + id="value_int64_min", + doc={"arr": [{"k": "a", "v": INT64_MIN}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": INT64_MIN}, + msg="$arrayToObject should preserve INT64_MIN value", + ), + ExpressionTestCase( + id="value_decimal128_max", + doc={"arr": [{"k": "a", "v": DECIMAL128_MAX}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": DECIMAL128_MAX}, + msg="$arrayToObject should preserve DECIMAL128_MAX value", + ), + ExpressionTestCase( + id="value_decimal128_min", + doc={"arr": [{"k": "a", "v": DECIMAL128_MIN}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": DECIMAL128_MIN}, + msg="$arrayToObject should preserve DECIMAL128_MIN value", + ), +] + +# Property [Nested Values]: $arrayToObject preserves nested arrays and documents as values. +NESTED_BSON_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="nested_bson_in_object_value", + doc={"arr": [{"k": "a", "v": {"x": Int64(1), "y": DECIMAL128_TWO_AND_HALF}}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": {"x": Int64(1), "y": DECIMAL128_TWO_AND_HALF}}, + msg="$arrayToObject should preserve nested BSON types in object value", + ), + ExpressionTestCase( + id="nested_bson_in_array_value", + doc={ + "arr": [ + { + "k": "a", + "v": [ + MinKey(), + datetime(2024, 1, 1, tzinfo=timezone.utc), + ObjectId("000000000000000000000001"), + ], + } + ] + }, + expression={"$arrayToObject": "$arr"}, + expected={ + "a": [ + MinKey(), + datetime(2024, 1, 1, tzinfo=timezone.utc), + ObjectId("000000000000000000000001"), + ] + }, + msg="$arrayToObject should preserve nested BSON types in array value", + ), + ExpressionTestCase( + id="deeply_nested_bson", + doc={"arr": [{"k": "a", "v": {"x": [{"y": DECIMAL128_ONE_AND_HALF}, Timestamp(0, 0)]}}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": {"x": [{"y": DECIMAL128_ONE_AND_HALF}, Timestamp(0, 0)]}}, + msg="$arrayToObject should preserve deeply nested BSON types", + ), + ExpressionTestCase( + id="nested_array_not_interpreted_as_kv", + doc={"arr": [{"k": "a", "v": [["level2", {"x": 1}]]}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": [["level2", {"x": 1}]]}, + msg="$arrayToObject should preserve nested array as value without interpreting as k/v", + ), +] + +# Property [Duplicate Numeric Keys]: last value wins for duplicate keys of differing numeric types. +NUMERIC_EQUIVALENCE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="duplicate_key_int_then_int64", + doc={"arr": [{"k": "a", "v": 1}, {"k": "a", "v": Int64(2)}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": Int64(2)}, + msg="$arrayToObject should keep the last Int64 value for a duplicate key", + ), + ExpressionTestCase( + id="duplicate_key_int_then_decimal128", + doc={"arr": [{"k": "a", "v": 1}, {"k": "a", "v": Decimal128("2")}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": Decimal128("2")}, + msg="$arrayToObject should keep the last Decimal128 value for a duplicate key", + ), + ExpressionTestCase( + id="duplicate_key_decimal128_then_double", + doc={"arr": [{"k": "a", "v": Decimal128("1")}, {"k": "a", "v": 2.0}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": 2.0}, + msg="$arrayToObject should keep the last double value for a duplicate key", + ), +] + +# Property [Mixed Types]: $arrayToObject preserves multiple mixed BSON value types in one array. +MIXED_BSON_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="kv_mixed_bson_types", + doc={ + "arr": [ + {"k": "int64", "v": Int64(1)}, + {"k": "dec", "v": DECIMAL128_ONE_AND_HALF}, + {"k": "dt", "v": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + {"k": "oid", "v": ObjectId("000000000000000000000001")}, + {"k": "bin", "v": Binary(b"\x01", 0)}, + {"k": "ts", "v": Timestamp(0, 0)}, + {"k": "min", "v": MinKey()}, + ] + }, + expression={"$arrayToObject": "$arr"}, + expected={ + "int64": Int64(1), + "dec": DECIMAL128_ONE_AND_HALF, + "dt": datetime(2024, 1, 1, tzinfo=timezone.utc), + "oid": ObjectId("000000000000000000000001"), + "bin": b"\x01", + "ts": Timestamp(0, 0), + "min": MinKey(), + }, + msg="$arrayToObject should preserve multiple mixed BSON types in one conversion", + ), +] + +ALL_BSON_TESTS = ( + BSON_KV_TESTS + + BSON_PAIR_TESTS + + SPECIAL_NUMERIC_TESTS + + BOUNDARY_TESTS + + NESTED_BSON_TESTS + + NUMERIC_EQUIVALENCE_TESTS + + MIXED_BSON_TESTS +) + + +# Float NaN needs a dedicated test because NaN does not compare equal to itself. +def test_arrayToObject_float_nan_value(collection): + """Test $arrayToObject preserves float NaN value.""" + result = execute_expression(collection, {"$arrayToObject": {"$literal": [["a", FLOAT_NAN]]}}) + assert_expression_result( + result, + expected={"a": pytest.approx(math.nan, nan_ok=True)}, + msg="$arrayToObject should preserve a NaN value", + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayToObject/test_arrayToObject_core_behavior.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayToObject/test_arrayToObject_core_behavior.py new file mode 100644 index 000000000..1bcd93032 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayToObject/test_arrayToObject_core_behavior.py @@ -0,0 +1,410 @@ +""" +Core behavior tests for $arrayToObject expression. + +Tests both input forms (k/v documents and two-element arrays), empty arrays, +duplicate keys, format equivalence, field ordering, case sensitivity, +value edge cases, key edge cases, and large inputs. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.lazy_payload import lazy +from documentdb_tests.framework.parametrize import pytest_params + +# Property [K/V Form]: $arrayToObject builds an object from {k, v} document entries. +KV_FORM_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="kv_single_pair", + doc={"arr": [{"k": "a", "v": 1}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": 1}, + msg="$arrayToObject should convert single k/v pair", + ), + ExpressionTestCase( + id="kv_multiple_pairs", + doc={"arr": [{"k": "a", "v": 1}, {"k": "b", "v": 2}, {"k": "c", "v": 3}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": 1, "b": 2, "c": 3}, + msg="$arrayToObject should convert multiple k/v pairs", + ), + ExpressionTestCase( + id="kv_string_values", + doc={"arr": [{"k": "name", "v": "Alice"}, {"k": "city", "v": "Mycity"}]}, + expression={"$arrayToObject": "$arr"}, + expected={"name": "Alice", "city": "Mycity"}, + msg="$arrayToObject should convert k/v pairs with string values", + ), + ExpressionTestCase( + id="kv_mixed_value_types", + doc={ + "arr": [ + {"k": "int", "v": 1}, + {"k": "str", "v": "hello"}, + {"k": "bool", "v": True}, + {"k": "null", "v": None}, + ] + }, + expression={"$arrayToObject": "$arr"}, + expected={"int": 1, "str": "hello", "bool": True, "null": None}, + msg="$arrayToObject should convert k/v pairs with mixed value types", + ), + ExpressionTestCase( + id="kv_nested_object_value", + doc={"arr": [{"k": "obj", "v": {"x": 1, "y": 2}}]}, + expression={"$arrayToObject": "$arr"}, + expected={"obj": {"x": 1, "y": 2}}, + msg="$arrayToObject should convert k/v pair with nested object value", + ), + ExpressionTestCase( + id="kv_array_value", + doc={"arr": [{"k": "arr", "v": [1, 2, 3]}]}, + expression={"$arrayToObject": "$arr"}, + expected={"arr": [1, 2, 3]}, + msg="$arrayToObject should convert k/v pair with array value", + ), +] + +# Property [Pair Form]: $arrayToObject builds an object from two-element [key, value] arrays. +TWO_ELEM_FORM_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="pair_single", + doc={"arr": [["a", 1]]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": 1}, + msg="$arrayToObject should convert single two-element pair", + ), + ExpressionTestCase( + id="pair_multiple", + doc={"arr": [["a", 1], ["b", 2], ["c", 3]]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": 1, "b": 2, "c": 3}, + msg="$arrayToObject should convert multiple two-element pairs", + ), + ExpressionTestCase( + id="pair_string_values", + doc={"arr": [["name", "Alice"], ["city", "Mycity"]]}, + expression={"$arrayToObject": "$arr"}, + expected={"name": "Alice", "city": "Mycity"}, + msg="$arrayToObject should convert pairs with string values", + ), + ExpressionTestCase( + id="pair_mixed_value_types", + doc={"arr": [["int", 1], ["str", "hello"], ["bool", True], ["null", None]]}, + expression={"$arrayToObject": "$arr"}, + expected={"int": 1, "str": "hello", "bool": True, "null": None}, + msg="$arrayToObject should convert pairs with mixed value types", + ), + ExpressionTestCase( + id="pair_nested_object_value", + doc={"arr": [["obj", {"x": 1, "y": 2}]]}, + expression={"$arrayToObject": "$arr"}, + expected={"obj": {"x": 1, "y": 2}}, + msg="$arrayToObject should convert pair with nested object value", + ), + ExpressionTestCase( + id="pair_array_value", + doc={"arr": [["arr", [1, 2, 3]]]}, + expression={"$arrayToObject": "$arr"}, + expected={"arr": [1, 2, 3]}, + msg="$arrayToObject should convert pair with array value", + ), +] + +# Property [Empty And Null]: $arrayToObject returns {} for an empty array and null for null input. +EMPTY_AND_NULL_ARRAY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="empty_array", + doc={"arr": []}, + expression={"$arrayToObject": "$arr"}, + expected={}, + msg="$arrayToObject should return empty object for empty array", + ), + ExpressionTestCase( + id="null_array", + doc={"arr": None}, + expression={"$arrayToObject": "$arr"}, + expected=None, + msg="$arrayToObject should return null for null array", + ), +] + +# Property [Duplicate Keys]: when keys repeat, $arrayToObject keeps the last value. +DUPLICATE_KEY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="kv_duplicate_keys", + doc={"arr": [{"k": "a", "v": 1}, {"k": "a", "v": 2}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": 2}, + msg="$arrayToObject should keep the last value for duplicate keys (k/v form)", + ), + ExpressionTestCase( + id="pair_duplicate_keys", + doc={"arr": [["a", 1], ["a", 2]]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": 2}, + msg="$arrayToObject should keep the last value for duplicate keys (pair form)", + ), + ExpressionTestCase( + id="kv_triple_duplicate", + doc={"arr": [{"k": "x", "v": 1}, {"k": "x", "v": 2}, {"k": "x", "v": 3}]}, + expression={"$arrayToObject": "$arr"}, + expected={"x": 3}, + msg="$arrayToObject should keep the last of three duplicate keys", + ), + ExpressionTestCase( + id="pair_dup_different_types", + doc={"arr": [["a", 1], ["a", "hello"]]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": "hello"}, + msg="$arrayToObject should keep the last value even with different value types", + ), + ExpressionTestCase( + id="pair_dup_interspersed", + doc={"arr": [["a", 1], ["b", 2], ["a", 3]]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": 3, "b": 2}, + msg="$arrayToObject should keep the last value with interspersed duplicate keys", + ), + ExpressionTestCase( + id="kv_dup_interspersed", + doc={"arr": [{"k": "a", "v": 1}, {"k": "b", "v": 2}, {"k": "a", "v": 3}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": 3, "b": 2}, + msg="$arrayToObject should keep the last value with interspersed duplicates (k/v form)", + ), + ExpressionTestCase( + id="kv_reversed_field_order", + doc={"arr": [{"v": "val", "k": "key"}]}, + expression={"$arrayToObject": "$arr"}, + expected={"key": "val"}, + msg="$arrayToObject should work regardless of k/v field order in document", + ), +] + +# Property [Key Characters]: $arrayToObject accepts unicode, emoji, and spaced keys. +KEY_EDGE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="unicode_key", + doc={"arr": [{"k": "日本語", "v": 1}]}, + expression={"$arrayToObject": "$arr"}, + expected={"日本語": 1}, + msg="$arrayToObject should accept a unicode key", + ), + ExpressionTestCase( + id="emoji_key", + doc={"arr": [{"k": "🔑", "v": "value"}]}, + expression={"$arrayToObject": "$arr"}, + expected={"🔑": "value"}, + msg="$arrayToObject should accept an emoji key", + ), + ExpressionTestCase( + id="key_with_spaces", + doc={"arr": [["key with spaces", 1]]}, + expression={"$arrayToObject": "$arr"}, + expected={"key with spaces": 1}, + msg="$arrayToObject should accept a key with spaces", + ), + ExpressionTestCase( + id="numeric_string_keys", + doc={"arr": [["0", "a"], ["1", "b"]]}, + expression={"$arrayToObject": "$arr"}, + expected={"0": "a", "1": "b"}, + msg="$arrayToObject should treat numeric string keys as strings", + ), + ExpressionTestCase( + id="underscore_id_key", + doc={"arr": [["_id", 1]]}, + expression={"$arrayToObject": "$arr"}, + expected={"_id": 1}, + msg="$arrayToObject should accept _id as a key", + ), + ExpressionTestCase( + id="operator_like_key", + doc={"arr": [["$set", 1]]}, + expression={"$arrayToObject": "$arr"}, + expected={"$set": 1}, + msg="$arrayToObject should accept an operator-like key", + ), + ExpressionTestCase( + id="very_long_key", + doc={"arr": [["k" * 1_024, 1]]}, + expression={"$arrayToObject": "$arr"}, + expected={"k" * 1_024: 1}, + msg="$arrayToObject should not truncate a very long key", + ), +] + +# Property [Key Handling]: $arrayToObject treats keys case-sensitively and preserves arbitrary +# nested/empty values. Output field-order preservation is verified separately in +# test_arrayToObject_preserves_field_order (a plain object comparison is order-insensitive). +# Property [Value Types]: $arrayToObject preserves complex value types. +EDGE_CASE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="case_sensitive_keys_kv", + doc={"arr": [{"k": "price", "v": 24}, {"k": "PRICE", "v": 100}]}, + expression={"$arrayToObject": "$arr"}, + expected={"price": 24, "PRICE": 100}, + msg="$arrayToObject should treat case-differing keys as distinct", + ), + ExpressionTestCase( + id="case_sensitive_keys_pair", + doc={"arr": [["price", 24], ["PRICE", 100]]}, + expression={"$arrayToObject": "$arr"}, + expected={"price": 24, "PRICE": 100}, + msg="$arrayToObject should treat case-differing keys as distinct (pair form)", + ), + ExpressionTestCase( + id="deeply_nested_object_value", + doc={"arr": [["key", {"a": {"b": {"c": {"d": 1}}}}]]}, + expression={"$arrayToObject": "$arr"}, + expected={"key": {"a": {"b": {"c": {"d": 1}}}}}, + msg="$arrayToObject should handle deeply nested object", + ), + ExpressionTestCase( + id="deeply_nested_array_value", + doc={"arr": [["key", [[[[1]]]]]]}, + expression={"$arrayToObject": "$arr"}, + expected={"key": [[[[1]]]]}, + msg="$arrayToObject should handle deeply nested array", + ), + ExpressionTestCase( + id="empty_object_value", + doc={"arr": [["key", {}]]}, + expression={"$arrayToObject": "$arr"}, + expected={"key": {}}, + msg="$arrayToObject should handle empty object value", + ), + ExpressionTestCase( + id="empty_array_value", + doc={"arr": [["key", []]]}, + expression={"$arrayToObject": "$arr"}, + expected={"key": []}, + msg="$arrayToObject should handle empty array value", + ), + ExpressionTestCase( + id="empty_string_value", + doc={"arr": [["key", ""]]}, + expression={"$arrayToObject": "$arr"}, + expected={"key": ""}, + msg="$arrayToObject should handle empty string value", + ), + ExpressionTestCase( + id="large_string_value", + doc={"arr": [["key", "x" * 10_240]]}, + expression={"$arrayToObject": "$arr"}, + expected={"key": "x" * 10_240}, + msg="$arrayToObject should handle large string value", + ), +] + +ALL_TESTS = ( + KV_FORM_TESTS + + TWO_ELEM_FORM_TESTS + + EMPTY_AND_NULL_ARRAY_TESTS + + DUPLICATE_KEY_TESTS + + KEY_EDGE_TESTS + + EDGE_CASE_TESTS +) + +TEST_SUBSET_FOR_LITERAL: list[ExpressionTestCase] = [ + ExpressionTestCase( + "kv_single_pair_literal", + doc=None, + expression={"$arrayToObject": {"$literal": [{"k": "a", "v": 1}]}}, + expected={"a": 1}, + msg="$arrayToObject should build object from literal kv pair", + ), + ExpressionTestCase( + "pair_single_literal", + doc=None, + expression={"$arrayToObject": {"$literal": [["a", 1]]}}, + expected={"a": 1}, + msg="$arrayToObject should build object from literal two-element pair", + ), + ExpressionTestCase( + "empty_array_literal", + doc=None, + expression={"$arrayToObject": {"$literal": []}}, + expected={}, + msg="$arrayToObject should return empty object for literal empty array", + ), + ExpressionTestCase( + "kv_duplicate_keys_literal", + doc=None, + expression={"$arrayToObject": {"$literal": [{"k": "a", "v": 1}, {"k": "a", "v": 2}]}}, + expected={"a": 2}, + msg="$arrayToObject should use last value for literal duplicate keys", + ), + ExpressionTestCase( + "case_sensitive_keys_kv_literal", + doc=None, + expression={"$arrayToObject": {"$literal": [{"k": "Name", "v": 1}, {"k": "name", "v": 2}]}}, + expected={"Name": 1, "name": 2}, + msg="$arrayToObject should treat literal keys as case-sensitive", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(TEST_SUBSET_FOR_LITERAL)) +def test_arrayToObject_literal(collection, test): + """Test $arrayToObject with literal values.""" + result = execute_expression(collection, test.expression) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_arrayToObject_insert(collection, test): + """Test $arrayToObject with values from inserted documents.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +@pytest.mark.parametrize( + "large_arr", + [ + pytest.param( + lazy(lambda: [[f"key_{i}", i] for i in range(10_000)]), id="two_element_pairs" + ), + pytest.param( + lazy(lambda: [{"k": f"key_{i}", "v": i} for i in range(10_000)]), id="kv_documents" + ), + ], +) +def test_arrayToObject_large_array(collection, large_arr): + """Test $arrayToObject builds a 10,000-field object from pair and k/v forms.""" + expected = {f"key_{i}": i for i in range(10_000)} + result = execute_expression(collection, {"$arrayToObject": {"$literal": large_arr}}) + assert_expression_result( + result, + expected=expected, + msg="$arrayToObject should build a 10,000-field object", + ) + + +def test_arrayToObject_preserves_field_order(collection): + """Test $arrayToObject preserves input pair order in the output object. + + Wrapped in $objectToArray so the assertion observes key order; a plain object + comparison is order-insensitive and would not detect a reordering. + """ + result = execute_expression( + collection, + {"$objectToArray": {"$arrayToObject": {"$literal": [["z", 1], ["a", 2], ["m", 3]]}}}, + ) + assert_expression_result( + result, + expected=[{"k": "z", "v": 1}, {"k": "a", "v": 2}, {"k": "m", "v": 3}], + msg="$arrayToObject should preserve input field order in the output", + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayToObject/test_arrayToObject_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayToObject/test_arrayToObject_errors.py new file mode 100644 index 000000000..a45e10e21 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayToObject/test_arrayToObject_errors.py @@ -0,0 +1,449 @@ +""" +Error tests for $arrayToObject expression. + +Tests non-array input, invalid element format, non-string keys, +and wrong arity errors. +""" + +from datetime import datetime + +import pytest +from bson import Binary, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + ARRAY_TO_OBJECT_INVALID_ELEMENT_ERROR, + ARRAY_TO_OBJECT_INVALID_KV_DOC_ERROR, + ARRAY_TO_OBJECT_INVALID_PAIR_ERROR, + ARRAY_TO_OBJECT_KV_KEY_NOT_STRING_ERROR, + ARRAY_TO_OBJECT_MIXED_KV_THEN_PAIR_ERROR, + ARRAY_TO_OBJECT_MIXED_PAIR_THEN_KV_ERROR, + ARRAY_TO_OBJECT_NOT_ARRAY_ERROR, + ARRAY_TO_OBJECT_NULL_BYTE_KV_KEY_ERROR, + ARRAY_TO_OBJECT_NULL_BYTE_PAIR_KEY_ERROR, + ARRAY_TO_OBJECT_PAIR_KEY_NOT_STRING_ERROR, + ARRAY_TO_OBJECT_WRONG_FIELD_NAMES_ERROR, + EXPRESSION_TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Array Type Strictness]: $arrayToObject rejects a non-array input. +NOT_ARRAY_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="string_input", + doc={"arr": "hello"}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_NOT_ARRAY_ERROR, + msg="$arrayToObject should reject string input", + ), + ExpressionTestCase( + id="int_input", + doc={"arr": 42}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_NOT_ARRAY_ERROR, + msg="$arrayToObject should reject int input", + ), + ExpressionTestCase( + id="bool_input", + doc={"arr": True}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_NOT_ARRAY_ERROR, + msg="$arrayToObject should reject bool input", + ), + ExpressionTestCase( + id="object_input", + doc={"arr": {"a": 1}}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_NOT_ARRAY_ERROR, + msg="$arrayToObject should reject object input", + ), + ExpressionTestCase( + id="double_input", + doc={"arr": 3.14}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_NOT_ARRAY_ERROR, + msg="$arrayToObject should reject double input", + ), + ExpressionTestCase( + id="decimal128_input", + doc={"arr": Decimal128("1")}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_NOT_ARRAY_ERROR, + msg="$arrayToObject should reject decimal128 input", + ), + ExpressionTestCase( + id="int64_input", + doc={"arr": Int64(1)}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_NOT_ARRAY_ERROR, + msg="$arrayToObject should reject int64 input", + ), + ExpressionTestCase( + id="objectid_input", + doc={"arr": ObjectId()}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_NOT_ARRAY_ERROR, + msg="$arrayToObject should reject objectid input", + ), + ExpressionTestCase( + id="datetime_input", + doc={"arr": datetime(2024, 1, 1)}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_NOT_ARRAY_ERROR, + msg="$arrayToObject should reject datetime input", + ), + ExpressionTestCase( + id="binary_input", + doc={"arr": Binary(b"x", 0)}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_NOT_ARRAY_ERROR, + msg="$arrayToObject should reject binary input", + ), + ExpressionTestCase( + id="regex_input", + doc={"arr": Regex("x")}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_NOT_ARRAY_ERROR, + msg="$arrayToObject should reject regex input", + ), + ExpressionTestCase( + id="maxkey_input", + doc={"arr": MaxKey()}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_NOT_ARRAY_ERROR, + msg="$arrayToObject should reject maxkey input", + ), + ExpressionTestCase( + id="minkey_input", + doc={"arr": MinKey()}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_NOT_ARRAY_ERROR, + msg="$arrayToObject should reject minkey input", + ), + ExpressionTestCase( + id="timestamp_input", + doc={"arr": Timestamp(0, 0)}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_NOT_ARRAY_ERROR, + msg="$arrayToObject should reject timestamp input", + ), +] + +# Property [Element Format]: $arrayToObject rejects an element that is not a k/v doc or pair. +INVALID_ELEMENT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="element_is_string", + doc={"arr": ["not_a_pair"]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_INVALID_ELEMENT_ERROR, + msg="$arrayToObject should reject string element", + ), + ExpressionTestCase( + id="element_is_int", + doc={"arr": [42]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_INVALID_ELEMENT_ERROR, + msg="$arrayToObject should reject int element", + ), + ExpressionTestCase( + id="element_is_null", + doc={"arr": [None]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_INVALID_ELEMENT_ERROR, + msg="$arrayToObject should reject null element", + ), + ExpressionTestCase( + id="element_is_bool", + doc={"arr": [True]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_INVALID_ELEMENT_ERROR, + msg="$arrayToObject should reject bool element", + ), + ExpressionTestCase( + id="element_is_double", + doc={"arr": [3.14]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_INVALID_ELEMENT_ERROR, + msg="$arrayToObject should reject double element", + ), + ExpressionTestCase( + id="element_is_objectid", + doc={"arr": [ObjectId()]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_INVALID_ELEMENT_ERROR, + msg="$arrayToObject should reject ObjectId element", + ), + ExpressionTestCase( + id="kv_missing_v", + doc={"arr": [{"k": "a"}]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_INVALID_KV_DOC_ERROR, + msg="$arrayToObject should reject k/v doc missing v field", + ), + ExpressionTestCase( + id="kv_missing_k", + doc={"arr": [{"v": 1}]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_INVALID_KV_DOC_ERROR, + msg="$arrayToObject should reject k/v doc missing k field", + ), + ExpressionTestCase( + id="kv_extra_field", + doc={"arr": [{"k": "a", "v": 1, "extra": 2}]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_INVALID_KV_DOC_ERROR, + msg="$arrayToObject should reject k/v doc with extra field", + ), + ExpressionTestCase( + id="kv_empty_doc", + doc={"arr": [{}]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_INVALID_KV_DOC_ERROR, + msg="$arrayToObject should reject empty document", + ), + ExpressionTestCase( + id="kv_wrong_field_names", + doc={"arr": [{"y": "x", "x": "y"}]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_WRONG_FIELD_NAMES_ERROR, + msg="$arrayToObject should reject wrong field names", + ), + ExpressionTestCase( + id="kv_uppercase_K", + doc={"arr": [{"K": "k1", "v": 2}]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_WRONG_FIELD_NAMES_ERROR, + msg="$arrayToObject should reject uppercase K (case-sensitive)", + ), + ExpressionTestCase( + id="kv_uppercase_V", + doc={"arr": [{"k": "k1", "V": 2}]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_WRONG_FIELD_NAMES_ERROR, + msg="$arrayToObject should reject uppercase V (case-sensitive)", + ), + ExpressionTestCase( + id="kv_key_value_names", + doc={"arr": [{"key": "k1", "value": "v1"}]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_WRONG_FIELD_NAMES_ERROR, + msg="$arrayToObject should reject 'key'/'value' instead of 'k'/'v'", + ), + ExpressionTestCase( + id="pair_one_element", + doc={"arr": [["a"]]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_INVALID_PAIR_ERROR, + msg="$arrayToObject should reject one-element array pair", + ), + ExpressionTestCase( + id="pair_three_elements", + doc={"arr": [["a", 1, 2]]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_INVALID_PAIR_ERROR, + msg="$arrayToObject should reject three-element array pair", + ), + ExpressionTestCase( + id="pair_empty_array", + doc={"arr": [[]]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_INVALID_PAIR_ERROR, + msg="$arrayToObject should reject empty array pair", + ), +] + +# Property [Key Type Strictness]: $arrayToObject rejects a non-string key. +KEY_NOT_STRING_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="kv_int_key", + doc={"arr": [{"k": 1, "v": "val"}]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_KV_KEY_NOT_STRING_ERROR, + msg="$arrayToObject should reject int key in k/v form", + ), + ExpressionTestCase( + id="kv_bool_key", + doc={"arr": [{"k": True, "v": "val"}]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_KV_KEY_NOT_STRING_ERROR, + msg="$arrayToObject should reject bool key in k/v form", + ), + ExpressionTestCase( + id="kv_null_key", + doc={"arr": [{"k": None, "v": "val"}]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_KV_KEY_NOT_STRING_ERROR, + msg="$arrayToObject should reject null key in k/v form", + ), + ExpressionTestCase( + id="kv_array_key", + doc={"arr": [{"k": [1], "v": "val"}]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_KV_KEY_NOT_STRING_ERROR, + msg="$arrayToObject should reject array key in k/v form", + ), + ExpressionTestCase( + id="kv_object_key", + doc={"arr": [{"k": {"x": 1}, "v": "val"}]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_KV_KEY_NOT_STRING_ERROR, + msg="$arrayToObject should reject object key in k/v form", + ), + ExpressionTestCase( + id="kv_double_key", + doc={"arr": [{"k": 1.5, "v": "val"}]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_KV_KEY_NOT_STRING_ERROR, + msg="$arrayToObject should reject double key in k/v form", + ), + ExpressionTestCase( + id="kv_int64_key", + doc={"arr": [{"k": Int64(1), "v": "val"}]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_KV_KEY_NOT_STRING_ERROR, + msg="$arrayToObject should reject Int64 key in k/v form", + ), + ExpressionTestCase( + id="kv_decimal128_key", + doc={"arr": [{"k": Decimal128("1"), "v": "val"}]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_KV_KEY_NOT_STRING_ERROR, + msg="$arrayToObject should reject Decimal128 key in k/v form", + ), + ExpressionTestCase( + id="pair_int_key", + doc={"arr": [[1, "val"]]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_PAIR_KEY_NOT_STRING_ERROR, + msg="$arrayToObject should reject int key in pair form", + ), + ExpressionTestCase( + id="pair_bool_key", + doc={"arr": [[True, "val"]]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_PAIR_KEY_NOT_STRING_ERROR, + msg="$arrayToObject should reject bool key in pair form", + ), + ExpressionTestCase( + id="pair_null_key", + doc={"arr": [[None, "val"]]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_PAIR_KEY_NOT_STRING_ERROR, + msg="$arrayToObject should reject null key in pair form", + ), + ExpressionTestCase( + id="pair_array_key", + doc={"arr": [[[1], "val"]]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_PAIR_KEY_NOT_STRING_ERROR, + msg="$arrayToObject should reject array key in pair form", + ), + ExpressionTestCase( + id="pair_object_key", + doc={"arr": [[{"x": 1}, "val"]]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_PAIR_KEY_NOT_STRING_ERROR, + msg="$arrayToObject should reject object key in pair form", + ), + ExpressionTestCase( + id="pair_double_key", + doc={"arr": [[1.5, "val"]]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_PAIR_KEY_NOT_STRING_ERROR, + msg="$arrayToObject should reject double key in pair form", + ), + ExpressionTestCase( + id="pair_int64_key", + doc={"arr": [[Int64(1), "val"]]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_PAIR_KEY_NOT_STRING_ERROR, + msg="$arrayToObject should reject Int64 key in pair form", + ), + ExpressionTestCase( + id="pair_decimal128_key", + doc={"arr": [[Decimal128("1"), "val"]]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_PAIR_KEY_NOT_STRING_ERROR, + msg="$arrayToObject should reject Decimal128 key in pair form", + ), +] + +# Property [Mixed Formats]: $arrayToObject rejects arrays mixing k/v doc and pair forms. +MIXED_FORMAT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="mixed_kv_then_pair", + doc={"arr": [{"k": "price", "v": 24}, ["item", "apple"]]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_MIXED_KV_THEN_PAIR_ERROR, + msg="$arrayToObject should reject a k/v doc followed by a pair", + ), + ExpressionTestCase( + id="mixed_pair_then_kv", + doc={"arr": [["item", "apple"], {"k": "price", "v": 24}]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_MIXED_PAIR_THEN_KV_ERROR, + msg="$arrayToObject should reject a pair followed by a k/v doc", + ), + ExpressionTestCase( + id="mixed_pair_then_non_array", + doc={"arr": [["a", 1], 123]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_MIXED_PAIR_THEN_KV_ERROR, + msg="$arrayToObject should reject a pair followed by a non-array element", + ), +] + +# Property [Null Byte Key]: $arrayToObject rejects a key containing a null byte. +NULL_BYTE_KEY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="null_byte_in_key_pair", + doc={"arr": [["a\x00b", "value"]]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_NULL_BYTE_PAIR_KEY_ERROR, + msg="$arrayToObject should reject a null byte in a key (pair form)", + ), + ExpressionTestCase( + id="null_byte_in_key_kv", + doc={"arr": [{"k": "a\x00b", "v": "value"}]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_NULL_BYTE_KV_KEY_ERROR, + msg="$arrayToObject should reject a null byte in a key (k/v form)", + ), +] + +ALL_TESTS = ( + NOT_ARRAY_ERROR_TESTS + + INVALID_ELEMENT_TESTS + + KEY_NOT_STRING_TESTS + + MIXED_FORMAT_TESTS + + NULL_BYTE_KEY_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_arrayToObject_insert(collection, test): + """Test $arrayToObject error cases with values from inserted documents.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +# Property [Arity]: $arrayToObject requires exactly one argument. +ARITY_ERROR_TESTS = [ + pytest.param({"$arrayToObject": [[], []]}, id="two_args"), +] + + +@pytest.mark.parametrize("expr", ARITY_ERROR_TESTS) +def test_arrayToObject_arity_error(collection, expr): + """Test $arrayToObject errors with wrong number of arguments.""" + result = execute_expression(collection, expr) + assert_expression_result(result, error_code=EXPRESSION_TYPE_MISMATCH_ERROR) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayToObject/test_arrayToObject_expressions.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayToObject/test_arrayToObject_expressions.py new file mode 100644 index 000000000..e5bd305e9 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayToObject/test_arrayToObject_expressions.py @@ -0,0 +1,182 @@ +""" +Expression and field path tests for $arrayToObject expression. + +Tests field path lookups, composite paths, key edge cases, +system variables, and null/missing handling. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Field Path]: $arrayToObject resolves a field-path array argument. +FIELD_LOOKUP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="nested_field_path", + expression={"$arrayToObject": "$a.b"}, + doc={"a": {"b": [{"k": "x", "v": 1}]}}, + expected={"x": 1}, + msg="$arrayToObject should resolve nested field path", + ), + ExpressionTestCase( + id="nonexistent_field_null", + expression={"$arrayToObject": "$a.nonexistent"}, + doc={"a": {"missing": 1}}, + expected=None, + msg="$arrayToObject should return null for a non-existent field", + ), + ExpressionTestCase( + id="deeply_nested_field", + expression={"$arrayToObject": "$a.b.c"}, + doc={"a": {"b": {"c": [{"k": "x", "v": 1}]}}}, + expected={"x": 1}, + msg="$arrayToObject should resolve deeply nested field path", + ), +] + +# Property [Composite Path]: $arrayToObject resolves a composite array built from a dotted path. +COMPOSITE_PATH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="composite_array_path", + expression={"$arrayToObject": "$a.b"}, + doc={"a": [{"b": {"k": "x", "v": 1}}, {"b": {"k": "y", "v": 2}}]}, + expected={"x": 1, "y": 2}, + msg="$arrayToObject should resolve a composite array path to a valid k/v array", + ), +] + +# Property [Key Characters]: $arrayToObject preserves special key characters from expression input. +KEY_EDGE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="empty_string_key", + expression={"$arrayToObject": "$arr"}, + doc={"arr": [{"k": "", "v": 1}]}, + expected={"": 1}, + msg="$arrayToObject should handle empty string key", + ), + ExpressionTestCase( + id="key_with_dots", + expression={"$arrayToObject": "$arr"}, + doc={"arr": [{"k": "a.b.c", "v": 1}]}, + expected={"a.b.c": 1}, + msg="$arrayToObject should handle key with dots", + ), + ExpressionTestCase( + id="key_with_dollar", + expression={"$arrayToObject": "$arr"}, + doc={"arr": [{"k": "$field", "v": 1}]}, + expected={"$field": 1}, + msg="$arrayToObject should handle key with dollar sign", + ), +] + +# Property [Variables]: $arrayToObject works with $let and system variables like $$ROOT. +SYSTEM_VAR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="let_variable", + expression={"$let": {"vars": {"arr": "$arr"}, "in": {"$arrayToObject": "$$arr"}}}, + doc={"arr": [["a", 1]]}, + expected={"a": 1}, + msg="$arrayToObject should work with $let variable", + ), + ExpressionTestCase( + id="root_variable", + expression={"$arrayToObject": "$$ROOT.pairs"}, + doc={"_id": 1, "pairs": [["a", 1]]}, + expected={"a": 1}, + msg="$arrayToObject should work with $$ROOT", + ), + ExpressionTestCase( + id="current_variable", + expression={"$arrayToObject": "$$CURRENT.pairs"}, + doc={"_id": 2, "pairs": [["a", 1]]}, + expected={"a": 1}, + msg="$arrayToObject should treat $$CURRENT like the field path", + ), +] + +# Property [Null Propagation]: $arrayToObject returns null when the field path is null or missing. +NULL_MISSING_EXPR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="missing_field", + expression={"$arrayToObject": "$nonexistent"}, + doc={"other": 1}, + expected=None, + msg="$arrayToObject should return null for a missing field", + ), + ExpressionTestCase( + id="missing_input_type_is_null", + expression={"$type": {"$arrayToObject": "$nonexistent"}}, + doc={"x": 1}, + expected="null", + msg="$arrayToObject should produce null type for a missing field", + ), +] + +# Property [Expression Inputs]: $arrayToObject evaluates expressions and array-expression +# literals that produce the input array, not just field paths and $literal arrays. +# Property [Expression Input]: $arrayToObject accepts expression operators as input. +EXPRESSION_INPUT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="expression_operator_input", + expression={"$arrayToObject": {"$concatArrays": ["$a", "$b"]}}, + doc={"a": [["k1", 1]], "b": [["k2", 2]]}, + expected={"k1": 1, "k2": 2}, + msg="$arrayToObject should evaluate a $concatArrays expression that builds the input array", + ), + ExpressionTestCase( + id="array_expression_input", + expression={"$arrayToObject": [[["$k", "$v"]]]}, + doc={"k": "x", "v": 5}, + expected={"x": 5}, + msg="$arrayToObject should evaluate an array expression containing field references", + ), + ExpressionTestCase( + id="map_expression_input", + expression={ + "$arrayToObject": {"$map": {"input": "$pairs", "as": "p", "in": ["$$p.k", "$$p.v"]}} + }, + doc={"pairs": [{"k": "a", "v": 1}, {"k": "b", "v": 2}]}, + expected={"a": 1, "b": 2}, + msg="$arrayToObject should evaluate a $map expression that builds the input array", + ), +] + +# Property [Array Index Path]: numeric path components like ".0" address object keys, not +# array positions, in aggregation expression context. +# Property [Numeric Path]: $arrayToObject resolves numeric path components on objects. +ARRAY_INDEX_PATH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="object_numeric_key_path", + expression={"$arrayToObject": "$a.0"}, + doc={"a": {"0": [["k", 1]]}}, + expected={"k": 1}, + msg="$arrayToObject should resolve a numeric key path through an object, not an index", + ), +] + +ALL_EXPR_TESTS = ( + FIELD_LOOKUP_TESTS + + COMPOSITE_PATH_TESTS + + KEY_EDGE_TESTS + + SYSTEM_VAR_TESTS + + NULL_MISSING_EXPR_TESTS + + EXPRESSION_INPUT_TESTS + + ARRAY_INDEX_PATH_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_EXPR_TESTS)) +def test_arrayToObject_expression(collection, test): + """Test $arrayToObject with field paths and expressions.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayToObject/test_smoke_expression_arrayToObject.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayToObject/test_smoke_arrayToObject.py similarity index 88% rename from documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayToObject/test_smoke_expression_arrayToObject.py rename to documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayToObject/test_smoke_arrayToObject.py index d589cb42b..8f8dff973 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayToObject/test_smoke_expression_arrayToObject.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayToObject/test_smoke_arrayToObject.py @@ -31,4 +31,6 @@ def test_smoke_expression_arrayToObject(collection): ) expected = [{"_id": 1, "obj": {"a": 1, "b": 2}}, {"_id": 2, "obj": {"x": 10, "y": 20}}] - assertSuccess(result, expected, msg="Should support $arrayToObject expression") + assertSuccess( + result, expected, "Should support $arrayToObject expression", ignore_doc_order=True + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/concatArrays/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/concatArrays/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/concatArrays/test_concatArrays_bson_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/concatArrays/test_concatArrays_bson_types.py new file mode 100644 index 000000000..0a21fbf5a --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/concatArrays/test_concatArrays_bson_types.py @@ -0,0 +1,224 @@ +""" +BSON type element preservation tests for $concatArrays expression. + +Tests that various BSON types are preserved when concatenating arrays, +including special numeric values and boundary values. +""" + +from datetime import datetime, timezone +from uuid import UUID + +import pytest +from bson import Binary, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ONE_AND_HALF, + DECIMAL128_TRAILING_ZERO, + DECIMAL128_TWO_AND_HALF, + DOUBLE_NEGATIVE_ZERO, + FLOAT_INFINITY, + FLOAT_NEGATIVE_INFINITY, + INT32_MAX, + INT32_MIN, + INT64_MAX, + INT64_MIN, +) + +# Property [Type Preservation]: $concatArrays preserves each element's BSON type. +BSON_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="int64_values", + doc={"arr0": [Int64(1), Int64(2)], "arr1": [Int64(3)]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=[Int64(1), Int64(2), Int64(3)], + msg="$concatArrays should preserve Int64 values", + ), + ExpressionTestCase( + id="decimal128_values", + doc={ + "arr0": [DECIMAL128_ONE_AND_HALF], + "arr1": [DECIMAL128_TWO_AND_HALF, Decimal128("3.5")], + }, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=[DECIMAL128_ONE_AND_HALF, DECIMAL128_TWO_AND_HALF, Decimal128("3.5")], + msg="$concatArrays should preserve Decimal128 values", + ), + ExpressionTestCase( + id="datetime_values", + doc={ + "arr0": [datetime(2024, 1, 1, tzinfo=timezone.utc)], + "arr1": [datetime(2024, 6, 1, tzinfo=timezone.utc)], + }, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=[ + datetime(2024, 1, 1, tzinfo=timezone.utc), + datetime(2024, 6, 1, tzinfo=timezone.utc), + ], + msg="$concatArrays should preserve datetime values", + ), + ExpressionTestCase( + id="objectid_values", + doc={ + "arr0": [ObjectId("000000000000000000000001")], + "arr1": [ObjectId("000000000000000000000002")], + }, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=[ + ObjectId("000000000000000000000001"), + ObjectId("000000000000000000000002"), + ], + msg="$concatArrays should preserve ObjectId values", + ), + ExpressionTestCase( + id="binary_values", + doc={"arr0": [Binary(b"\x01", 0)], "arr1": [Binary(b"\x02", 0)]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=[b"\x01", b"\x02"], + msg="$concatArrays should preserve Binary values", + ), + ExpressionTestCase( + id="regex_values", + doc={"arr0": [Regex("^a", "i")], "arr1": [Regex("^b", "i")]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=[Regex("^a", "i"), Regex("^b", "i")], + msg="$concatArrays should preserve Regex values", + ), + ExpressionTestCase( + id="timestamp_values", + doc={"arr0": [Timestamp(1, 0)], "arr1": [Timestamp(2, 0)]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=[Timestamp(1, 0), Timestamp(2, 0)], + msg="$concatArrays should preserve Timestamp values", + ), + ExpressionTestCase( + id="minkey_maxkey", + doc={"arr0": [MinKey()], "arr1": [MaxKey()]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=[MinKey(), MaxKey()], + msg="$concatArrays should preserve MinKey/MaxKey values", + ), + ExpressionTestCase( + id="uuid_values", + doc={ + "arr0": [Binary.from_uuid(UUID("01234567-89ab-cdef-fedc-ba9876543210"))], + "arr1": [Binary.from_uuid(UUID("fedcba98-7654-3210-0123-456789abcdef"))], + }, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=[ + Binary.from_uuid(UUID("01234567-89ab-cdef-fedc-ba9876543210")), + Binary.from_uuid(UUID("fedcba98-7654-3210-0123-456789abcdef")), + ], + msg="$concatArrays should preserve UUID binary values", + ), +] + +# Property [Mixed Types]: $concatArrays concatenates arrays holding mixed BSON element types. +MIXED_BSON_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="mixed_bson_types", + doc={"arr0": [1, "two", Int64(3)], "arr1": [Decimal128("4"), True, None, MinKey()]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=[1, "two", Int64(3), Decimal128("4"), True, None, MinKey()], + msg="$concatArrays should concatenate mixed BSON types preserving each", + ), + ExpressionTestCase( + id="mixed_dates_and_ids", + doc={ + "arr0": [ + datetime(2024, 1, 1, tzinfo=timezone.utc), + ObjectId("000000000000000000000001"), + ], + "arr1": [Timestamp(1, 0), Binary(b"\x01", 0)], + }, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=[ + datetime(2024, 1, 1, tzinfo=timezone.utc), + ObjectId("000000000000000000000001"), + Timestamp(1, 0), + b"\x01", + ], + msg="$concatArrays should concatenate dates, ObjectIds, timestamps, and binary", + ), + ExpressionTestCase( + id="mixed_extremes", + doc={"arr0": [MinKey(), FLOAT_NEGATIVE_INFINITY, None], "arr1": [FLOAT_INFINITY, MaxKey()]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=[MinKey(), FLOAT_NEGATIVE_INFINITY, None, FLOAT_INFINITY, MaxKey()], + msg="$concatArrays should concatenate MinKey, MaxKey, infinities, and null", + ), +] + +# Property [Special Numerics]: $concatArrays preserves NaN, Infinity, and negative zero elements. +SPECIAL_NUMERIC_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="infinity_values", + doc={"arr0": [FLOAT_INFINITY], "arr1": [FLOAT_NEGATIVE_INFINITY]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=[FLOAT_INFINITY, FLOAT_NEGATIVE_INFINITY], + msg="$concatArrays should preserve infinity values", + ), + ExpressionTestCase( + id="decimal128_infinity", + doc={"arr0": [DECIMAL128_INFINITY], "arr1": [DECIMAL128_NEGATIVE_INFINITY]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=[DECIMAL128_INFINITY, DECIMAL128_NEGATIVE_INFINITY], + msg="$concatArrays should preserve Decimal128 infinity values", + ), + ExpressionTestCase( + id="boundary_values", + doc={"arr0": [INT32_MIN, INT32_MAX], "arr1": [INT64_MIN, INT64_MAX]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=[INT32_MIN, INT32_MAX, INT64_MIN, INT64_MAX], + msg="$concatArrays should preserve numeric boundary values", + ), + ExpressionTestCase( + id="negative_zero", + doc={"arr0": [DOUBLE_NEGATIVE_ZERO], "arr1": [DECIMAL128_NEGATIVE_ZERO]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=[DOUBLE_NEGATIVE_ZERO, DECIMAL128_NEGATIVE_ZERO], + msg="$concatArrays should preserve negative zero values", + ), +] + +# Property [Element Identity]: $concatArrays preserves element values and order. +ELEMENT_PRESERVATION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="decimal128_trailing_zeros", + doc={"arr0": [DECIMAL128_TRAILING_ZERO], "arr1": [Decimal128("1.00"), Decimal128("1.000")]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=[DECIMAL128_TRAILING_ZERO, Decimal128("1.00"), Decimal128("1.000")], + msg="$concatArrays should preserve Decimal128 trailing zeros", + ), + ExpressionTestCase( + id="decimal128_nan", + doc={"arr0": [DECIMAL128_NAN], "arr1": [Decimal128("1")]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=[DECIMAL128_NAN, Decimal128("1")], + msg="$concatArrays should preserve a Decimal128 NaN element", + ), +] + +ALL_BSON_TESTS = ( + BSON_TYPE_TESTS + MIXED_BSON_TESTS + SPECIAL_NUMERIC_TESTS + ELEMENT_PRESERVATION_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_BSON_TESTS)) +def test_concatArrays_bson_insert(collection, test): + """Test $concatArrays BSON types with values from inserted documents.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/concatArrays/test_concatArrays_core_behavior.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/concatArrays/test_concatArrays_core_behavior.py new file mode 100644 index 000000000..87adebc2c --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/concatArrays/test_concatArrays_core_behavior.py @@ -0,0 +1,361 @@ +""" +Core behavior tests for $concatArrays expression. + +Tests concatenation of arrays with various element types, empty arrays, +single arrays, multiple arrays, nested arrays, duplicates, null +propagation, and large arrays. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Concatenation]: $concatArrays joins multiple arrays into one in argument order. +BASIC_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "two_int_arrays", + doc={"arr0": [1, 2], "arr1": [3, 4]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=[1, 2, 3, 4], + msg="$concatArrays should concatenate two int arrays", + ), + ExpressionTestCase( + "two_string_arrays", + doc={"arr0": ["a", "b"], "arr1": ["c", "d"]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=["a", "b", "c", "d"], + msg="$concatArrays should concatenate two string arrays", + ), + ExpressionTestCase( + "three_arrays", + doc={"arr0": [1, 2], "arr1": [3, 4], "arr2": [5, 6]}, + expression={"$concatArrays": ["$arr0", "$arr1", "$arr2"]}, + expected=[1, 2, 3, 4, 5, 6], + msg="$concatArrays should concatenate three arrays", + ), + ExpressionTestCase( + "mixed_type_elements", + doc={"arr0": [1, "two"], "arr1": [True, None, {"a": 1}]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=[1, "two", True, None, {"a": 1}], + msg="$concatArrays should concatenate arrays with mixed types", + ), +] + +# Property [Empty Arrays]: $concatArrays treats empty arrays as contributing no elements. +EMPTY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "both_empty", + doc={"arr0": [], "arr1": []}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=[], + msg="$concatArrays should return empty array for two empty arrays", + ), + ExpressionTestCase( + "first_empty", + doc={"arr0": [], "arr1": [1, 2]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=[1, 2], + msg="$concatArrays should return second array when first is empty", + ), + ExpressionTestCase( + "second_empty", + doc={"arr0": [1, 2], "arr1": []}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=[1, 2], + msg="$concatArrays should return first array when second is empty", + ), + ExpressionTestCase( + "all_empty", + doc={"arr0": [], "arr1": [], "arr2": []}, + expression={"$concatArrays": ["$arr0", "$arr1", "$arr2"]}, + expected=[], + msg="$concatArrays should return empty array for all empty inputs", + ), + ExpressionTestCase( + "no_arguments", + doc={"x": 1}, + expression={"$concatArrays": []}, + expected=[], + msg="$concatArrays should return an empty array for no arguments", + ), + ExpressionTestCase( + "empty_between_nonempty", + doc={"arr0": [1], "arr1": [], "arr2": [2]}, + expression={"$concatArrays": ["$arr0", "$arr1", "$arr2"]}, + expected=[1, 2], + msg="$concatArrays should skip an empty array between non-empty arrays", + ), + ExpressionTestCase( + "multiple_empty", + doc={"arr0": [], "arr1": [], "arr2": [], "arr3": []}, + expression={"$concatArrays": ["$arr0", "$arr1", "$arr2", "$arr3"]}, + expected=[], + msg="$concatArrays should return an empty array for multiple empty arrays", + ), +] + +# Property [Single Array]: $concatArrays returns a single array argument unchanged. +SINGLE_ARRAY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "single_array", + doc={"arr0": [1, 2, 3]}, + expression={"$concatArrays": ["$arr0"]}, + expected=[1, 2, 3], + msg="$concatArrays should return the single array unchanged", + ), + ExpressionTestCase( + "single_empty_array", + doc={"arr0": []}, + expression={"$concatArrays": ["$arr0"]}, + expected=[], + msg="$concatArrays should return empty array for single empty input", + ), +] + +# Property [Top Level Only]: $concatArrays joins at the top level without flattening. +NESTED_ARRAY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_subarrays", + doc={"arr0": [[1, 2]], "arr1": [[3, 4]]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=[[1, 2], [3, 4]], + msg="$concatArrays should concatenate top-level, not flatten subarrays", + ), + ExpressionTestCase( + "mixed_nested", + doc={"arr0": [[1], "two"], "arr1": [[3, 4]]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=[[1], "two", [3, 4]], + msg="$concatArrays should concatenate mixed nested elements", + ), + ExpressionTestCase( + "deeply_nested", + doc={"arr0": [[[1]]], "arr1": [[[2]]]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=[[[1]], [[2]]], + msg="$concatArrays should preserve deeply nested array elements", + ), + ExpressionTestCase( + "empty_nested", + doc={"arr0": [[]], "arr1": [[]]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=[[], []], + msg="$concatArrays should preserve empty nested arrays as elements", + ), +] + +# Property [Duplicates]: $concatArrays keeps duplicate elements from the inputs. +DUPLICATE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "duplicate_elements", + doc={"arr0": [1, 2, 3], "arr1": [2, 3, 4]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=[1, 2, 3, 2, 3, 4], + msg="$concatArrays should preserve duplicate elements across arrays", + ), + ExpressionTestCase( + "identical_arrays", + doc={"arr0": [1, 2], "arr1": [1, 2]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=[1, 2, 1, 2], + msg="$concatArrays should concatenate identical arrays", + ), +] + +# Property [Null Propagation]: $concatArrays returns null when any argument is null or missing. +NULL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_first_arg", + doc={"arr0": None, "arr1": [1, 2]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=None, + msg="$concatArrays should return null when first argument is null", + ), + ExpressionTestCase( + "null_second_arg", + doc={"arr0": [1, 2], "arr1": None}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=None, + msg="$concatArrays should return null when second argument is null", + ), + ExpressionTestCase( + "all_null", + doc={"arr0": None, "arr1": None}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=None, + msg="$concatArrays should return null when all arguments are null", + ), + ExpressionTestCase( + "null_among_three", + doc={"arr0": [1], "arr1": None, "arr2": [2]}, + expression={"$concatArrays": ["$arr0", "$arr1", "$arr2"]}, + expected=None, + msg="$concatArrays should return null when any argument is null", + ), + ExpressionTestCase( + "null_elements_in_arrays", + doc={"arr0": [1, None], "arr1": [None, 2]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=[1, None, None, 2], + msg="$concatArrays should preserve null elements within arrays", + ), +] + +# Property [Object Elements]: $concatArrays concatenates arrays of documents intact. +OBJECT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "arrays_of_objects", + doc={"arr0": [{"a": 1}], "arr1": [{"b": 2}]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=[{"a": 1}, {"b": 2}], + msg="$concatArrays should concatenate arrays of objects", + ), + ExpressionTestCase( + "objects_with_arrays", + doc={"arr0": [{"items": [1, 2]}], "arr1": [{"items": [3, 4]}]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=[{"items": [1, 2]}, {"items": [3, 4]}], + msg="$concatArrays should preserve inner arrays in objects", + ), +] + +# Property [Large Arrays]: $concatArrays concatenates large arrays. +_LARGE_A = list(range(500)) +_LARGE_B = list(range(500, 1000)) + +# Property [Large Arrays]: $concatArrays concatenates large arrays. +LARGE_ARRAY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "large_arrays", + doc={"arr0": _LARGE_A, "arr1": _LARGE_B}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=list(range(1000)), + msg="$concatArrays should concatenate large arrays", + ), + ExpressionTestCase( + "two_5000_arrays", + doc={"arr0": list(range(5_000)), "arr1": list(range(5_000, 10000))}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=list(range(10_000)), + msg="$concatArrays should concatenate two large arrays into 10,000 elements", + ), + ExpressionTestCase( + "one_large_one_small", + doc={"arr0": list(range(10_000)), "arr1": [10_000]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=list(range(10_001)), + msg="$concatArrays should concatenate a large array and a small array", + ), + ExpressionTestCase( + "100_single_element_arrays", + doc={f"arr{i}": [i] for i in range(100)}, + expression={"$concatArrays": [f"$arr{i}" for i in range(100)]}, + expected=list(range(100)), + msg="$concatArrays should concatenate 100 single-element arrays", + ), +] + +# Property [Many Arrays]: $concatArrays concatenates many array arguments. +MANY_ARRAYS_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "five_arrays", + doc={"arr0": [1], "arr1": [2], "arr2": [3], "arr3": [4], "arr4": [5]}, + expression={"$concatArrays": ["$arr0", "$arr1", "$arr2", "$arr3", "$arr4"]}, + expected=[1, 2, 3, 4, 5], + msg="$concatArrays should concatenate five arrays", + ), + ExpressionTestCase( + "ten_empty_arrays", + doc={f"arr{i}": [] for i in range(10)}, + expression={"$concatArrays": [f"$arr{i}" for i in range(10)]}, + expected=[], + msg="$concatArrays should concatenate ten empty arrays", + ), + ExpressionTestCase( + "fifty_arrays", + doc={f"arr{i}": [i] for i in range(50)}, + expression={"$concatArrays": [f"$arr{i}" for i in range(50)]}, + expected=list(range(50)), + msg="$concatArrays should concatenate 50 arrays", + ), +] + +ALL_TESTS = ( + BASIC_TESTS + + EMPTY_TESTS + + SINGLE_ARRAY_TESTS + + NESTED_ARRAY_TESTS + + DUPLICATE_TESTS + + NULL_TESTS + + OBJECT_TESTS + + LARGE_ARRAY_TESTS + + MANY_ARRAYS_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_concatArrays_insert(collection, test): + """Test $concatArrays with values from inserted documents.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +TEST_SUBSET_FOR_LITERAL: list[ExpressionTestCase] = [ + ExpressionTestCase( + "two_int_arrays", + doc=None, + expression={"$concatArrays": [{"$literal": [1, 2]}, {"$literal": [3, 4]}]}, + expected=[1, 2, 3, 4], + msg="$concatArrays should concatenate two literal int arrays", + ), + ExpressionTestCase( + "three_arrays", + doc=None, + expression={ + "$concatArrays": [{"$literal": [1, 2]}, {"$literal": [3, 4]}, {"$literal": [5, 6]}] + }, + expected=[1, 2, 3, 4, 5, 6], + msg="$concatArrays should concatenate three literal arrays", + ), + ExpressionTestCase( + "both_empty", + doc=None, + expression={"$concatArrays": [{"$literal": []}, {"$literal": []}]}, + expected=[], + msg="$concatArrays should return empty for two literal empty arrays", + ), + ExpressionTestCase( + "single_array", + doc=None, + expression={"$concatArrays": [{"$literal": [1, 2, 3]}]}, + expected=[1, 2, 3], + msg="$concatArrays should return single literal array unchanged", + ), + ExpressionTestCase( + "nested_subarrays", + doc=None, + expression={"$concatArrays": [{"$literal": [[1, 2]]}, {"$literal": [[3, 4]]}]}, + expected=[[1, 2], [3, 4]], + msg="$concatArrays should concatenate literal nested subarrays", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(TEST_SUBSET_FOR_LITERAL)) +def test_concatArrays_literal(collection, test): + """Test $concatArrays with literal values.""" + result = execute_expression(collection, test.expression) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/concatArrays/test_concatArrays_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/concatArrays/test_concatArrays_errors.py new file mode 100644 index 000000000..c3198576c --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/concatArrays/test_concatArrays_errors.py @@ -0,0 +1,322 @@ +""" +Error tests for $concatArrays expression. + +Tests non-array input (all BSON types, special numeric values, boundary values, +string edge cases). $concatArrays propagates null but errors on non-array, +non-null input. +""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import CONCAT_ARRAYS_NOT_ARRAY_ERROR +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_MAX, + DECIMAL128_MIN, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_ZERO, + DOUBLE_NEGATIVE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + INT32_MAX, + INT32_MIN, + INT64_MAX, + INT64_MIN, +) + +# Property [Array Type Strictness]: $concatArrays rejects a non-array argument. +NOT_ARRAY_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="string_input", + doc={"arr0": "hello", "arr1": [1]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject string input", + ), + ExpressionTestCase( + id="int_input", + doc={"arr0": 42, "arr1": [1]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject int input", + ), + ExpressionTestCase( + id="negative_int_input", + doc={"arr0": -42, "arr1": [1]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject negative int input", + ), + ExpressionTestCase( + id="bool_input", + doc={"arr0": True, "arr1": [1]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject bool input", + ), + ExpressionTestCase( + id="object_input", + doc={"arr0": {"a": 1}, "arr1": [1]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject object input", + ), + ExpressionTestCase( + id="double_input", + doc={"arr0": 3.14, "arr1": [1]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject double input", + ), + ExpressionTestCase( + id="negative_double_input", + doc={"arr0": -3.14, "arr1": [1]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject negative double input", + ), + ExpressionTestCase( + id="decimal128_input", + doc={"arr0": Decimal128("1"), "arr1": [1]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject decimal128 input", + ), + ExpressionTestCase( + id="int64_input", + doc={"arr0": Int64(1), "arr1": [1]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject int64 input", + ), + ExpressionTestCase( + id="objectid_input", + doc={"arr0": ObjectId(), "arr1": [1]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject objectid input", + ), + ExpressionTestCase( + id="datetime_input", + doc={"arr0": datetime(2024, 1, 1, tzinfo=timezone.utc), "arr1": [1]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject datetime input", + ), + ExpressionTestCase( + id="binary_input", + doc={"arr0": Binary(b"x", 0), "arr1": [1]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject binary input", + ), + ExpressionTestCase( + id="regex_input", + doc={"arr0": Regex("x"), "arr1": [1]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject regex input", + ), + ExpressionTestCase( + id="maxkey_input", + doc={"arr0": MaxKey(), "arr1": [1]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject maxkey input", + ), + ExpressionTestCase( + id="minkey_input", + doc={"arr0": MinKey(), "arr1": [1]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject minkey input", + ), + ExpressionTestCase( + id="timestamp_input", + doc={"arr0": Timestamp(0, 0), "arr1": [1]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject timestamp input", + ), + ExpressionTestCase( + id="non_array_second_arg", + doc={"arr0": [1], "arr1": 42}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject non-array in second position", + ), + ExpressionTestCase( + id="non_array_middle_arg", + doc={"arr0": [1], "arr1": "bad", "arr2": [2]}, + expression={"$concatArrays": ["$arr0", "$arr1", "$arr2"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject non-array in middle position", + ), +] + +# Property [Non-Array Numerics]: $concatArrays rejects special float/Decimal128 arguments. +SPECIAL_NUMERIC_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="nan_input", + doc={"arr0": FLOAT_NAN, "arr1": [1]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject NaN input", + ), + ExpressionTestCase( + id="inf_input", + doc={"arr0": FLOAT_INFINITY, "arr1": [1]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject Infinity input", + ), + ExpressionTestCase( + id="neg_inf_input", + doc={"arr0": FLOAT_NEGATIVE_INFINITY, "arr1": [1]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject -Infinity input", + ), + ExpressionTestCase( + id="neg_zero_input", + doc={"arr0": DOUBLE_NEGATIVE_ZERO, "arr1": [1]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject negative zero input", + ), + ExpressionTestCase( + id="decimal128_nan_input", + doc={"arr0": DECIMAL128_NAN, "arr1": [1]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject Decimal128 NaN input", + ), + ExpressionTestCase( + id="decimal128_inf_input", + doc={"arr0": DECIMAL128_INFINITY, "arr1": [1]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject Decimal128 Infinity input", + ), + ExpressionTestCase( + id="decimal128_neg_inf_input", + doc={"arr0": DECIMAL128_NEGATIVE_INFINITY, "arr1": [1]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject Decimal128 -Infinity input", + ), + ExpressionTestCase( + id="decimal128_neg_zero_input", + doc={"arr0": DECIMAL128_NEGATIVE_ZERO, "arr1": [1]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject Decimal128 -0 input", + ), +] + +# Property [Non-Array Boundaries]: $concatArrays rejects numeric boundary values as arguments. +BOUNDARY_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="int32_max_input", + doc={"arr0": INT32_MAX, "arr1": [1]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject INT32_MAX input", + ), + ExpressionTestCase( + id="int32_min_input", + doc={"arr0": INT32_MIN, "arr1": [1]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject INT32_MIN input", + ), + ExpressionTestCase( + id="int64_max_input", + doc={"arr0": INT64_MAX, "arr1": [1]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject INT64_MAX input", + ), + ExpressionTestCase( + id="int64_min_input", + doc={"arr0": INT64_MIN, "arr1": [1]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject INT64_MIN input", + ), + ExpressionTestCase( + id="decimal128_max_input", + doc={"arr0": DECIMAL128_MAX, "arr1": [1]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject DECIMAL128_MAX input", + ), + ExpressionTestCase( + id="decimal128_min_input", + doc={"arr0": DECIMAL128_MIN, "arr1": [1]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject DECIMAL128_MIN input", + ), +] + +# Property [Non-Array Strings]: $concatArrays rejects string arguments regardless of content. +STRING_EDGE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="comma_separated_string_input", + doc={"arr0": "1, 2, 3", "arr1": [1]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject comma-separated string", + ), + ExpressionTestCase( + id="json_like_string_input", + doc={"arr0": "[1, 2, 3]", "arr1": [1]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject JSON-like string", + ), + ExpressionTestCase( + id="empty_object_input", + doc={"arr0": {}, "arr1": [1]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject empty object as arg", + ), +] + +ALL_TESTS = ( + NOT_ARRAY_ERROR_TESTS + + SPECIAL_NUMERIC_ERROR_TESTS + + BOUNDARY_ERROR_TESTS + + STRING_EDGE_ERROR_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_concatArrays_not_array_insert(collection, test): + """Test $concatArrays error with non-array input from inserted documents.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +# Property [Array Type Strictness]: $concatArrays rejects an object expression argument. +def test_concatArrays_object_expression_input(collection): + """Test $concatArrays rejects an object expression that is not an array.""" + result = execute_expression_with_insert(collection, {"$concatArrays": [{"a": "$x"}]}, {"x": 1}) + assert_expression_result(result, error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/concatArrays/test_concatArrays_expressions.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/concatArrays/test_concatArrays_expressions.py new file mode 100644 index 000000000..98e6dd28a --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/concatArrays/test_concatArrays_expressions.py @@ -0,0 +1,328 @@ +""" +Expression and field path tests for $concatArrays expression. + +Tests field path lookups, composite paths, system variables, +and null/missing propagation via expressions. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Field Path]: $concatArrays resolves field-path array arguments. +FIELD_LOOKUP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="nested_field_path", + expression={"$concatArrays": ["$a.b", "$a.c"]}, + doc={"a": {"b": [1, 2], "c": [3, 4]}}, + expected=[1, 2, 3, 4], + msg="$concatArrays should resolve nested field paths", + ), + ExpressionTestCase( + id="deeply_nested_field", + expression={"$concatArrays": ["$a.b.c", "$a.b.d"]}, + doc={"a": {"b": {"c": [10], "d": [20]}}}, + expected=[10, 20], + msg="$concatArrays should resolve deeply nested field paths", + ), + ExpressionTestCase( + id="nonexistent_field_null", + expression={"$concatArrays": ["$a.nonexistent", "$b"]}, + doc={"a": {"missing": 1}, "b": [1]}, + expected=None, + msg="$concatArrays should propagate null for a non-existent field", + ), + ExpressionTestCase( + id="numeric_path_component_not_array_index", + expression={"$concatArrays": ["$a.0", [5]]}, + doc={"a": [[1, 2], [3, 4]]}, + expected=[5], + msg="$concatArrays should resolve $a.0 to an empty array in expression context", + ), + ExpressionTestCase( + id="nonexistent_nested_path_empty", + expression={"$concatArrays": ["$f.x", [3]]}, + doc={"f": [{"g": 1}, {"g": 2}]}, + expected=[3], + msg="$concatArrays should resolve a non-existent nested path to an empty array", + ), + ExpressionTestCase( + id="nested_array_of_object_path", + expression={"$concatArrays": ["$a.b.c", [3]]}, + doc={"a": {"b": [{"c": [1]}, {"c": [2]}]}}, + expected=[[1], [2], 3], + msg="$concatArrays should concatenate an array-of-arrays produced by mapping a field " + "over an array of objects", + ), +] + +# Property [Composite Path]: $concatArrays resolves composite arrays from dotted paths. +COMPOSITE_PATH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="composite_array", + expression={"$concatArrays": ["$x.y", [100]]}, + doc={"x": [{"y": 10}, {"y": 20}]}, + expected=[10, 20, 100], + msg="$concatArrays should resolve a composite array path from an array of objects", + ), + ExpressionTestCase( + id="composite_path_tags", + expression={"$concatArrays": ["$items.tags", ["d"]]}, + doc={"items": [{"tags": ["a", "b"]}, {"tags": ["c"]}]}, + expected=[["a", "b"], ["c"], "d"], + msg="$concatArrays should resolve $items.tags to an array of arrays", + ), +] + +# Property [Variables]: $concatArrays works with $let and system variables like $$ROOT. +LET_AND_VARIABLE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="let_variable", + expression={ + "$let": { + "vars": {"a": "$arr1", "b": "$arr2"}, + "in": {"$concatArrays": ["$$a", "$$b"]}, + } + }, + doc={"arr1": [1, 2], "arr2": [3, 4]}, + expected=[1, 2, 3, 4], + msg="$concatArrays should work with $let variables", + ), + ExpressionTestCase( + id="root_variable", + expression={"$concatArrays": ["$$ROOT.a", "$$ROOT.b"]}, + doc={"_id": 1, "a": [1], "b": [2]}, + expected=[1, 2], + msg="$concatArrays should work with $$ROOT", + ), + ExpressionTestCase( + id="current_variable", + expression={"$concatArrays": ["$$CURRENT.a", "$$CURRENT.b"]}, + doc={"_id": 2, "a": [1], "b": [2]}, + expected=[1, 2], + msg="$concatArrays should treat $$CURRENT like the field path", + ), + ExpressionTestCase( + id="let_null_variable", + expression={ + "$let": { + "vars": {"x": None}, + "in": {"$concatArrays": ["$$x", [1]]}, + } + }, + doc={"_placeholder": 1}, + expected=None, + msg="$concatArrays should return null for a null $let variable", + ), +] + +# Property [Null Propagation]: $concatArrays returns null when a field path is null or missing. +NULL_MISSING_EXPR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="missing_field", + expression={"$concatArrays": ["$nonexistent", [1]]}, + doc={"other": 1}, + expected=None, + msg="$concatArrays should propagate null for a missing field", + ), + ExpressionTestCase( + id="missing_input_type_is_null", + expression={"$type": {"$concatArrays": ["$nonexistent", [1]]}}, + doc={"x": 1}, + expected="null", + msg="$concatArrays should produce null type for a missing field", + ), + ExpressionTestCase( + id="remove_variable", + expression={"$concatArrays": ["$$REMOVE", [1]]}, + doc={"x": 1}, + expected=None, + msg="$concatArrays should return null when an argument is $$REMOVE", + ), + ExpressionTestCase( + id="missing_first_field", + expression={"$concatArrays": ["$a", "$b"]}, + doc={"b": [1]}, + expected=None, + msg="$concatArrays should return null when the first field is missing", + ), + ExpressionTestCase( + id="missing_last_field", + expression={"$concatArrays": ["$a", "$b"]}, + doc={"a": [1]}, + expected=None, + msg="$concatArrays should return null when the last field is missing", + ), + ExpressionTestCase( + id="missing_middle_field", + expression={"$concatArrays": ["$a", "$b", "$c"]}, + doc={"a": [1], "c": [3]}, + expected=None, + msg="$concatArrays should return null when a middle field is missing", + ), + ExpressionTestCase( + id="all_missing_fields", + expression={"$concatArrays": ["$a", "$b"]}, + doc={"_placeholder": 1}, + expected=None, + msg="$concatArrays should return null when all fields are missing", + ), + ExpressionTestCase( + id="missing_plus_null", + expression={"$concatArrays": ["$not_a_field", "$null_val"]}, + doc={"null_val": None}, + expected=None, + msg="$concatArrays should return null for a missing field plus null", + ), + ExpressionTestCase( + id="null_precedes_non_array", + expression={"$concatArrays": ["$arr", "$null_val", "$int_val"]}, + doc={"arr": [1, 2], "null_val": None, "int_val": 42}, + expected=None, + msg="$concatArrays should return null when a null precedes a non-array argument", + ), + ExpressionTestCase( + id="null_result_type_is_null", + expression={"$type": {"$concatArrays": ["$a", "$nonexistent"]}}, + doc={"a": [1]}, + expected="null", + msg="$concatArrays should produce null type, not missing, for a null result", + ), +] + +# Property [Nesting]: $concatArrays can be nested as an argument to itself. +SELF_COMPOSITION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="nested_concatArrays", + expression={"$concatArrays": [{"$concatArrays": ["$a", "$b"]}, "$c"]}, + doc={"a": [1], "b": [2], "c": [3]}, + expected=[1, 2, 3], + msg="$concatArrays should evaluate a nested $concatArrays argument", + ), + ExpressionTestCase( + id="double_nested_concatArrays", + expression={ + "$concatArrays": [{"$concatArrays": ["$a", "$b"]}, {"$concatArrays": ["$c", "$d"]}] + }, + doc={"a": [1], "b": [2], "c": [3], "d": [4]}, + expected=[1, 2, 3, 4], + msg="$concatArrays should evaluate nested $concatArrays in both arguments", + ), + ExpressionTestCase( + id="triple_depth_concatArrays", + expression={ + "$concatArrays": [{"$concatArrays": [{"$concatArrays": ["$a", "$b"]}, "$c"]}, "$d"] + }, + doc={"a": [1], "b": [2], "c": [3], "d": [4]}, + expected=[1, 2, 3, 4], + msg="$concatArrays should evaluate triple-nested $concatArrays", + ), +] + +# Property [Repeated Field]: $concatArrays repeats elements when the same field is referenced again. +SAME_FIELD_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="same_field_twice", + expression={"$concatArrays": ["$a", "$a"]}, + doc={"a": [1, 2, 3]}, + expected=[1, 2, 3, 1, 2, 3], + msg="$concatArrays should double elements when a field is referenced twice", + ), + ExpressionTestCase( + id="same_field_three_times", + expression={"$concatArrays": ["$a", "$a", "$a"]}, + doc={"a": [1]}, + expected=[1, 1, 1], + msg="$concatArrays should triple elements when a field is referenced three times", + ), + ExpressionTestCase( + id="self_concat_mixed_types", + expression={"$concatArrays": ["$a", "$a"]}, + doc={"a": [42, "string", {"key": "value"}, [1, 2], True]}, + expected=[ + 42, + "string", + {"key": "value"}, + [1, 2], + True, + 42, + "string", + {"key": "value"}, + [1, 2], + True, + ], + msg="$concatArrays should preserve all element types when self-concatenating", + ), +] + +# Property [Expression Inputs]: $concatArrays evaluates array expressions that produce +# array arguments. +# Property [Expression Input]: $concatArrays accepts expression operators as input. +EXPRESSION_INPUT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="array_expression_input", + expression={"$concatArrays": [["$x", "$y"], [3]]}, + doc={"x": 1, "y": 2}, + expected=[1, 2, 3], + msg="$concatArrays should resolve an array expression containing field references", + ), + ExpressionTestCase( + id="literal_then_field", + expression={"$concatArrays": [[1, 2, 3], "$a"]}, + doc={"a": [1, 2]}, + expected=[1, 2, 3, 1, 2], + msg="$concatArrays should preserve order for a literal followed by a field", + ), + ExpressionTestCase( + id="field_then_literal", + expression={"$concatArrays": ["$a", [1, 2, 3]]}, + doc={"a": [1, 2]}, + expected=[1, 2, 1, 2, 3], + msg="$concatArrays should preserve order for a field followed by a literal", + ), + ExpressionTestCase( + id="four_fields_with_empty_and_literal", + expression={"$concatArrays": ["$a", "$b", "$c", "$d", [], ["array"]]}, + doc={"a": [1, 2], "b": [3, 4], "c": [5, 6], "d": []}, + expected=[1, 2, 3, 4, 5, 6, "array"], + msg="$concatArrays should concatenate multiple fields and literals", + ), +] + +# Property [Object Elements]: $concatArrays preserves documents with special keys as elements. +SPECIAL_KEY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="special_object_keys", + expression={"$concatArrays": ["$a", "$b"]}, + doc={"a": [{"a.b": 1}], "b": [{"$x": 2}]}, + expected=[{"a.b": 1}, {"$x": 2}], + msg="$concatArrays should preserve objects with special keys", + ), +] + +ALL_EXPR_TESTS = ( + FIELD_LOOKUP_TESTS + + COMPOSITE_PATH_TESTS + + LET_AND_VARIABLE_TESTS + + NULL_MISSING_EXPR_TESTS + + SELF_COMPOSITION_TESTS + + SAME_FIELD_TESTS + + EXPRESSION_INPUT_TESTS + + SPECIAL_KEY_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_EXPR_TESTS)) +def test_concatArrays_expression(collection, test): + """Test $concatArrays with field paths and expressions.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/concatArrays/test_smoke_expression_concatArrays.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/concatArrays/test_smoke_concatArrays.py similarity index 87% rename from documentdb_tests/compatibility/tests/core/operator/expressions/array/concatArrays/test_smoke_expression_concatArrays.py rename to documentdb_tests/compatibility/tests/core/operator/expressions/array/concatArrays/test_smoke_concatArrays.py index 62ffe6471..9af309d97 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/array/concatArrays/test_smoke_expression_concatArrays.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/concatArrays/test_smoke_concatArrays.py @@ -28,4 +28,6 @@ def test_smoke_expression_concatArrays(collection): ) expected = [{"_id": 1, "combined": [1, 2, 3, 4]}, {"_id": 2, "combined": [5, 6, 7, 8]}] - assertSuccess(result, expected, msg="Should support $concatArrays expression") + assertSuccess( + result, expected, "Should support $concatArrays expression", ignore_doc_order=True + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_arrayToObject.py b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_arrayToObject.py new file mode 100644 index 000000000..78e4b046f --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_arrayToObject.py @@ -0,0 +1,73 @@ +""" +Combination tests for $arrayToObject composed with other operators. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + execute_expression_with_insert, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ARRAY_TO_OBJECT_INVALID_ELEMENT_ERROR +from documentdb_tests.framework.parametrize import pytest_params + +ARRAY_TO_OBJECT_COMBINATION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="arrayToObject_roundtrip_with_objectToArray", + expression={"$arrayToObject": {"$objectToArray": "$obj"}}, + doc={"obj": {"a": 1, "b": 2}}, + expected={"a": 1, "b": 2}, + msg="Roundtrip should restore original object", + ), + ExpressionTestCase( + id="arrayToObject_double_roundtrip", + expression={"$arrayToObject": {"$objectToArray": {"$arrayToObject": "$arr"}}}, + doc={"arr": [["a", 1], ["b", 2]]}, + expected={"a": 1, "b": 2}, + msg="Double roundtrip: array → object → array → object should restore", + ), + ExpressionTestCase( + id="arrayToObject_on_concatArrays", + expression={"$arrayToObject": {"$concatArrays": ["$a", "$b"]}}, + doc={"a": [["a", 1]], "b": [["b", 2]]}, + expected={"a": 1, "b": 2}, + msg="Should work on $concatArrays result", + ), + ExpressionTestCase( + id="arrayToObject_formats_produce_same_result", + expression={ + "$eq": [ + {"$arrayToObject": "$pairs"}, + {"$arrayToObject": "$kv"}, + ] + }, + doc={"pairs": [["a", 1], ["b", 2]], "kv": [{"k": "a", "v": 1}, {"k": "b", "v": 2}]}, + expected=True, + msg="Both formats should produce identical output", + ), + ExpressionTestCase( + id="arrayToObject_on_slice", + expression={"$arrayToObject": {"$slice": ["$arr", 2]}}, + doc={"arr": [["a", 1], ["b", 2], ["c", 3]]}, + expected={"a": 1, "b": 2}, + msg="Should work on $slice result", + ), + ExpressionTestCase( + id="arrayToObject_on_range_fails", + expression={"$arrayToObject": {"$range": ["$start", "$end"]}}, + doc={"start": 0, "end": 3}, + error_code=ARRAY_TO_OBJECT_INVALID_ELEMENT_ERROR, + msg="$range produces numbers, should fail", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(ARRAY_TO_OBJECT_COMBINATION_TESTS)) +def test_arrayToObject_combination(collection, test): + """Test $arrayToObject composed with other operators.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + expected = [{"result": test.expected}] if test.error_code is None else None + assertResult(result, expected=expected, error_code=test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_concatArrays.py b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_concatArrays.py new file mode 100644 index 000000000..6bad4c246 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_concatArrays.py @@ -0,0 +1,89 @@ +""" +Combination tests for $concatArrays composed with other operators. +""" + +import math + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + execute_expression_with_insert, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import FLOAT_NAN + +CONCAT_ARRAYS_COMBINATION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="concatArrays_on_range", + expression={"$concatArrays": [{"$range": [0, 3]}, {"$range": [3, 6]}]}, + doc={"x": 1}, + expected=[0, 1, 2, 3, 4, 5], + msg="Should concatenate two $range results", + ), + ExpressionTestCase( + id="size_of_concatArrays", + expression={"$size": {"$concatArrays": ["$a", "$b"]}}, + doc={"a": [1, 2], "b": [3, 4, 5]}, + expected=5, + msg="$size on $concatArrays result", + ), + ExpressionTestCase( + id="size_of_empty_concatArrays", + expression={"$size": {"$concatArrays": ["$a", "$b"]}}, + doc={"a": [], "b": []}, + expected=0, + msg="Size of empty concatenation", + ), + ExpressionTestCase( + id="concatArrays_reverseArray_concatArrays", + expression={ + "$concatArrays": [ + {"$reverseArray": {"$concatArrays": ["$a", "$b"]}}, + "$c", + ] + }, + doc={"a": [1, 2], "b": [3], "c": [4]}, + expected=[3, 2, 1, 4], + msg="Should concatenate reversed concat result with another array", + ), + ExpressionTestCase( + id="isArray_on_concatArrays", + expression={"$isArray": {"$concatArrays": ["$a", "$b"]}}, + doc={"a": [1], "b": [2]}, + expected=True, + msg="$isArray on $concatArrays result should return true", + ), + ExpressionTestCase( + id="in_found_in_concatArrays", + expression={"$in": [3, {"$concatArrays": ["$a", "$b"]}]}, + doc={"a": [1, 2], "b": [3, 4]}, + expected=True, + msg="Element found in concatenated array", + ), + ExpressionTestCase( + id="isArray_null_concatArrays", + expression={"$isArray": {"$concatArrays": ["$a", "$b"]}}, + doc={"a": None, "b": [1]}, + expected=False, + msg="Null result is not array", + ), + ExpressionTestCase( + id="float_nan_preserved", + expression={"$arrayElemAt": [{"$concatArrays": ["$a", "$b"]}, 0]}, + doc={"a": [FLOAT_NAN], "b": [1]}, + expected=pytest.approx(math.nan, nan_ok=True), + msg="Float NaN element preserved after concatenation", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(CONCAT_ARRAYS_COMBINATION_TESTS)) +def test_concatArrays_combination(collection, test): + """Test $concatArrays composed with other operators.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + expected = [{"result": test.expected}] if test.error_code is None else None + assertResult(result, expected=expected, error_code=test.error_code, msg=test.msg) From c2554cdeb1e4700aca7bfb7d75a81c8524ee3e16 Mon Sep 17 00:00:00 2001 From: Daniel Frankcom Date: Wed, 15 Jul 2026 14:33:46 -0700 Subject: [PATCH 32/35] Add $ln, $log, and $log10 tests (#671) Signed-off-by: Daniel Frankcom Co-authored-by: Mitchell Elholm Co-authored-by: Daniel Frankcom Signed-off-by: RyanGarfinkel <113050972+RyanGarfinkel@users.noreply.github.com> --- .../expressions/arithmetic/ln/__init__.py | 0 .../arithmetic/ln/test_ln_boundaries.py | 158 +++++++ .../arithmetic/ln/test_ln_errors.py | 254 +++++++++++ .../arithmetic/ln/test_ln_input_forms.py | 67 +++ .../arithmetic/ln/test_ln_magnitude.py | 217 +++++++++ .../arithmetic/ln/test_ln_non_finite.py | 68 +++ .../expressions/arithmetic/ln/test_ln_null.py | 43 ++ .../arithmetic/ln/test_ln_return_type.py | 58 +++ .../expressions/arithmetic/log/__init__.py | 0 .../arithmetic/log/test_log_base.py | 119 +++++ .../arithmetic/log/test_log_boundaries.py | 150 ++++++ .../arithmetic/log/test_log_errors.py | 430 ++++++++++++++++++ .../arithmetic/log/test_log_input_forms.py | 70 +++ .../arithmetic/log/test_log_magnitude.py | 417 +++++++++++++++++ .../arithmetic/log/test_log_non_finite.py | 127 ++++++ .../arithmetic/log/test_log_null.py | 86 ++++ .../arithmetic/log/test_log_return_type.py | 72 +++ .../expressions/arithmetic/log10/__init__.py | 0 .../arithmetic/log10/test_log10_boundaries.py | 160 +++++++ .../arithmetic/log10/test_log10_errors.py | 254 +++++++++++ .../log10/test_log10_input_forms.py | 70 +++ .../arithmetic/log10/test_log10_magnitude.py | 332 ++++++++++++++ .../arithmetic/log10/test_log10_non_finite.py | 68 +++ .../arithmetic/log10/test_log10_null.py | 43 ++ .../log10/test_log10_return_type.py | 58 +++ 25 files changed, 3321 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ln/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ln/test_ln_boundaries.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ln/test_ln_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ln/test_ln_input_forms.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ln/test_ln_magnitude.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ln/test_ln_non_finite.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ln/test_ln_null.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ln/test_ln_return_type.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/log/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/log/test_log_base.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/log/test_log_boundaries.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/log/test_log_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/log/test_log_input_forms.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/log/test_log_magnitude.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/log/test_log_non_finite.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/log/test_log_null.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/log/test_log_return_type.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/log10/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/log10/test_log10_boundaries.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/log10/test_log10_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/log10/test_log10_input_forms.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/log10/test_log10_magnitude.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/log10/test_log10_non_finite.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/log10/test_log10_null.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/log10/test_log10_return_type.py diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ln/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ln/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ln/test_ln_boundaries.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ln/test_ln_boundaries.py new file mode 100644 index 000000000..797b82f68 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ln/test_ln_boundaries.py @@ -0,0 +1,158 @@ +"""Tests for $ln at representable-range boundaries, including subnormal and extreme inputs.""" + +import pytest +from bson import Decimal128 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_MAX, + DECIMAL128_MIN_POSITIVE, + DECIMAL128_SMALL_EXPONENT, + DECIMAL128_TRAILING_ZERO, + DECIMAL128_ZERO, + DOUBLE_MAX_SAFE_INTEGER, + DOUBLE_MIN_SUBNORMAL, + DOUBLE_NEAR_MAX, + DOUBLE_NEAR_MIN, + DOUBLE_PRECISION_LOSS, + INT32_MAX, + INT32_MAX_MINUS_1, + INT64_MAX, + INT64_MAX_MINUS_1, +) + +# Property [Integer Boundaries]: $ln of the largest representable integers returns a finite double. +LN_INTEGER_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int32_max", + doc={"value": INT32_MAX}, + expression={"$ln": ["$value"]}, + expected=pytest.approx(21.487562596892644), + msg="$ln should return the natural log of INT32_MAX", + ), + ExpressionTestCase( + "int32_max_minus_1", + doc={"value": INT32_MAX_MINUS_1}, + expression={"$ln": ["$value"]}, + expected=pytest.approx(21.487562596426983), + msg="$ln should return the natural log of INT32_MAX minus one", + ), + ExpressionTestCase( + "int64_max", + doc={"value": INT64_MAX}, + expression={"$ln": ["$value"]}, + expected=pytest.approx(43.66827237527655), + msg="$ln should return the natural log of INT64_MAX", + ), + ExpressionTestCase( + "int64_max_minus_1", + doc={"value": INT64_MAX_MINUS_1}, + expression={"$ln": ["$value"]}, + expected=pytest.approx(43.66827237527655), + msg="$ln should return the natural log of INT64_MAX minus one", + ), +] + +# Property [Double Boundaries]: $ln at the double subnormal and near-limit range returns the +# expected large-magnitude values. +LN_DOUBLE_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "double_min_subnormal", + doc={"value": DOUBLE_MIN_SUBNORMAL}, + expression={"$ln": ["$value"]}, + expected=pytest.approx(-744.4400719213812), + msg="$ln should return a large negative value for the minimum subnormal double", + ), + ExpressionTestCase( + "double_near_min", + doc={"value": DOUBLE_NEAR_MIN}, + expression={"$ln": ["$value"]}, + expected=pytest.approx(-709.1962086421661), + msg="$ln should return a large negative value for a near-zero positive double", + ), + ExpressionTestCase( + "double_near_max", + doc={"value": DOUBLE_NEAR_MAX}, + expression={"$ln": ["$value"]}, + expected=pytest.approx(709.1962086421661), + msg="$ln should return a large positive value for a near-maximum double", + ), + ExpressionTestCase( + "double_max_safe_integer", + doc={"value": DOUBLE_MAX_SAFE_INTEGER}, + expression={"$ln": ["$value"]}, + expected=pytest.approx(36.7368005696771), + msg="$ln should return the natural log of the maximum safe integer double", + ), + ExpressionTestCase( + "double_precision_loss", + doc={"value": DOUBLE_PRECISION_LOSS}, + expression={"$ln": ["$value"]}, + expected=pytest.approx(36.7368005696771), + msg="$ln should return the same value as the maximum safe integer for the next " + "integer double, which is not representable", + ), + ExpressionTestCase( + "very_small_positive", + doc={"value": 1e-300}, + expression={"$ln": ["$value"]}, + expected=pytest.approx(-690.7755278982137), + msg="$ln should return a large negative value for a very small positive double", + ), +] + +# Property [Decimal128 Boundaries]: $ln of extreme decimal128 exponents returns a full-precision +# decimal128 result. +LN_DECIMAL_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "decimal_small_exponent", + doc={"value": DECIMAL128_SMALL_EXPONENT}, + expression={"$ln": ["$value"]}, + expected=Decimal128("-14144.78022626242263692252150612605"), + msg="$ln should return a large negative decimal128 for a decimal128 with a small exponent", + ), + ExpressionTestCase( + "decimal_min_positive", + doc={"value": DECIMAL128_MIN_POSITIVE}, + expression={"$ln": ["$value"]}, + expected=Decimal128("-14220.76553433122614449511522413063"), + msg="$ln should return a large negative decimal128 for the smallest positive decimal128", + ), + ExpressionTestCase( + "decimal_max", + doc={"value": DECIMAL128_MAX}, + expression={"$ln": ["$value"]}, + expected=Decimal128("14149.38539644841072829055748903542"), + msg="$ln should return a large positive decimal128 for the maximum decimal128", + ), + ExpressionTestCase( + "decimal_trailing_zero", + doc={"value": DECIMAL128_TRAILING_ZERO}, + expression={"$ln": ["$value"]}, + expected=DECIMAL128_ZERO, + msg="$ln should return zero for a decimal128 one with a trailing zero", + ), +] + +LN_BOUNDARY_ALL_TESTS = ( + LN_INTEGER_BOUNDARY_TESTS + LN_DOUBLE_BOUNDARY_TESTS + LN_DECIMAL_BOUNDARY_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(LN_BOUNDARY_ALL_TESTS)) +def test_ln_boundaries(collection, test_case: ExpressionTestCase): + """Test $ln representable-range boundary cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ln/test_ln_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ln/test_ln_errors.py new file mode 100644 index 000000000..1ea2c103d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ln/test_ln_errors.py @@ -0,0 +1,254 @@ +"""Tests for $ln domain, type, and arity errors.""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + EXPRESSION_TYPE_MISMATCH_ERROR, + LN_NON_POSITIVE_INPUT_ERROR, + NON_NUMERIC_TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_MAX_NEGATIVE, + DECIMAL128_MIN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ZERO, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_ZERO, + FLOAT_NEGATIVE_INFINITY, + INT32_MIN, + INT64_MIN, +) + +# Property [Domain]: $ln rejects non-positive inputs, including zero, negative zero, negative +# values, and negative infinity. +LN_DOMAIN_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "zero_int32", + doc={"value": 0}, + expression={"$ln": ["$value"]}, + error_code=LN_NON_POSITIVE_INPUT_ERROR, + msg="$ln should reject int32 zero", + ), + ExpressionTestCase( + "zero_double", + doc={"value": DOUBLE_ZERO}, + expression={"$ln": ["$value"]}, + error_code=LN_NON_POSITIVE_INPUT_ERROR, + msg="$ln should reject double zero", + ), + ExpressionTestCase( + "zero_decimal", + doc={"value": DECIMAL128_ZERO}, + expression={"$ln": ["$value"]}, + error_code=LN_NON_POSITIVE_INPUT_ERROR, + msg="$ln should reject decimal128 zero", + ), + ExpressionTestCase( + "negative_zero_double", + doc={"value": DOUBLE_NEGATIVE_ZERO}, + expression={"$ln": ["$value"]}, + error_code=LN_NON_POSITIVE_INPUT_ERROR, + msg="$ln should reject negative zero double", + ), + ExpressionTestCase( + "negative_zero_decimal", + doc={"value": DECIMAL128_NEGATIVE_ZERO}, + expression={"$ln": ["$value"]}, + error_code=LN_NON_POSITIVE_INPUT_ERROR, + msg="$ln should reject negative zero decimal128", + ), + ExpressionTestCase( + "negative_int32", + doc={"value": -1}, + expression={"$ln": ["$value"]}, + error_code=LN_NON_POSITIVE_INPUT_ERROR, + msg="$ln should reject a negative integer", + ), + ExpressionTestCase( + "negative_ten", + doc={"value": -10}, + expression={"$ln": ["$value"]}, + error_code=LN_NON_POSITIVE_INPUT_ERROR, + msg="$ln should reject negative ten", + ), + ExpressionTestCase( + "negative_double", + doc={"value": -0.5}, + expression={"$ln": ["$value"]}, + error_code=LN_NON_POSITIVE_INPUT_ERROR, + msg="$ln should reject a negative double", + ), + ExpressionTestCase( + "negative_decimal", + doc={"value": Decimal128("-1")}, + expression={"$ln": ["$value"]}, + error_code=LN_NON_POSITIVE_INPUT_ERROR, + msg="$ln should reject a negative decimal128", + ), + ExpressionTestCase( + "int32_min", + doc={"value": INT32_MIN}, + expression={"$ln": ["$value"]}, + error_code=LN_NON_POSITIVE_INPUT_ERROR, + msg="$ln should reject INT32_MIN", + ), + ExpressionTestCase( + "int64_min", + doc={"value": INT64_MIN}, + expression={"$ln": ["$value"]}, + error_code=LN_NON_POSITIVE_INPUT_ERROR, + msg="$ln should reject INT64_MIN", + ), + ExpressionTestCase( + "decimal_min", + doc={"value": DECIMAL128_MIN}, + expression={"$ln": ["$value"]}, + error_code=LN_NON_POSITIVE_INPUT_ERROR, + msg="$ln should reject the minimum decimal128", + ), + ExpressionTestCase( + "decimal_max_negative", + doc={"value": DECIMAL128_MAX_NEGATIVE}, + expression={"$ln": ["$value"]}, + error_code=LN_NON_POSITIVE_INPUT_ERROR, + msg="$ln should reject the smallest-magnitude negative decimal128", + ), + ExpressionTestCase( + "float_negative_infinity", + doc={"value": FLOAT_NEGATIVE_INFINITY}, + expression={"$ln": ["$value"]}, + error_code=LN_NON_POSITIVE_INPUT_ERROR, + msg="$ln should reject float negative infinity", + ), + ExpressionTestCase( + "decimal128_negative_infinity", + doc={"value": DECIMAL128_NEGATIVE_INFINITY}, + expression={"$ln": ["$value"]}, + error_code=LN_NON_POSITIVE_INPUT_ERROR, + msg="$ln should reject decimal128 negative infinity", + ), +] + +# Property [Type Strictness]: $ln rejects non-numeric input types. +LN_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + *[ + ExpressionTestCase( + f"type_{tid}", + doc={"value": val}, + expression={"$ln": ["$value"]}, + error_code=NON_NUMERIC_TYPE_MISMATCH_ERROR, + msg=f"$ln should reject a {tid} input", + ) + for tid, val in [ + ("string", "abc"), + ("bool", True), + ("array", [1, 2]), + ("object", {"a": 1}), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("regex", Regex("abc")), + ("binary", Binary(b"data")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("timestamp", Timestamp(1, 1)), + ("code", Code("function(){}")), + ] + ], + ExpressionTestCase( + "empty_array", + doc={"value": []}, + expression={"$ln": ["$value"]}, + error_code=NON_NUMERIC_TYPE_MISMATCH_ERROR, + msg="$ln should reject an empty array input", + ), + ExpressionTestCase( + "empty_object", + doc={"value": {}}, + expression={"$ln": ["$value"]}, + error_code=NON_NUMERIC_TYPE_MISMATCH_ERROR, + msg="$ln should reject an empty object input", + ), +] + +# Property [Non-Numeric Expression and Path Inputs]: array and object expression inputs, and field +# paths that resolve to an array, are delivered to $ln as values and rejected as non-numeric. +LN_EXPRESSION_INPUT_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "array_expression_input", + doc={"value": 10}, + expression={"$ln": [["$value"]]}, + error_code=NON_NUMERIC_TYPE_MISMATCH_ERROR, + msg="$ln should reject an array expression input without flattening it into arguments", + ), + ExpressionTestCase( + "object_expression_input", + doc={"value": 10}, + expression={"$ln": {"z": "$value"}}, + error_code=NON_NUMERIC_TYPE_MISMATCH_ERROR, + msg="$ln should reject an object expression input as a value rather than evaluating it", + ), + ExpressionTestCase( + "array_of_objects_path", + doc={"a": [{"b": 10}, {"b": 100}]}, + expression={"$ln": "$a.b"}, + error_code=NON_NUMERIC_TYPE_MISMATCH_ERROR, + msg="$ln should reject a field path that resolves to an array from an array of objects", + ), + ExpressionTestCase( + "array_index_path", + doc={"a": [{"b": 10}, {"b": 100}]}, + expression={"$ln": "$a.0.b"}, + error_code=NON_NUMERIC_TYPE_MISMATCH_ERROR, + msg="$ln should reject an array-index field path that resolves to an array in an " + "aggregation expression", + ), +] + +# Property [Arity]: $ln requires exactly one argument. +LN_ARITY_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "arity_zero", + doc={}, + expression={"$ln": []}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="$ln should reject zero arguments", + ), + ExpressionTestCase( + "arity_two", + doc={}, + expression={"$ln": [1, 2]}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="$ln should reject two arguments", + ), +] + +LN_ERROR_ALL_TESTS = ( + LN_DOMAIN_ERROR_TESTS + + LN_TYPE_ERROR_TESTS + + LN_EXPRESSION_INPUT_ERROR_TESTS + + LN_ARITY_ERROR_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(LN_ERROR_ALL_TESTS)) +def test_ln_errors(collection, test_case: ExpressionTestCase): + """Test $ln domain, type, and arity error cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ln/test_ln_input_forms.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ln/test_ln_input_forms.py new file mode 100644 index 000000000..e2ca6ae13 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ln/test_ln_input_forms.py @@ -0,0 +1,67 @@ +"""Tests for $ln argument forms, literal input, and nested expression input.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import DOUBLE_ZERO + +# Property [Argument Form]: $ln accepts its single argument bare or wrapped in a one-element array. +LN_ARGUMENT_FORM_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "form_array", + doc={"value": 1}, + expression={"$ln": ["$value"]}, + expected=DOUBLE_ZERO, + msg="$ln should accept its argument wrapped in a one-element array", + ), + ExpressionTestCase( + "form_bare", + doc={"value": 1}, + expression={"$ln": "$value"}, + expected=DOUBLE_ZERO, + msg="$ln should accept its argument without an array wrapper", + ), +] + +# Property [Literal Input]: $ln evaluates an inline literal argument, not only document fields. +LN_LITERAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "literal_input", + doc={}, + expression={"$ln": [1]}, + expected=DOUBLE_ZERO, + msg="$ln should return zero for an inline literal one", + ), +] + +# Property [Expression Input]: $ln evaluates a nested expression argument before taking the log. +LN_EXPRESSION_INPUT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_exp", + doc={}, + expression={"$ln": {"$exp": 1}}, + expected=pytest.approx(1.0), + msg="$ln should evaluate a nested $exp expression argument", + ), +] + +LN_INPUT_FORM_TESTS = LN_ARGUMENT_FORM_TESTS + LN_LITERAL_TESTS + LN_EXPRESSION_INPUT_TESTS + + +@pytest.mark.parametrize("test_case", pytest_params(LN_INPUT_FORM_TESTS)) +def test_ln_input_forms(collection, test_case: ExpressionTestCase): + """Test $ln argument form, literal, and nested expression input cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ln/test_ln_magnitude.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ln/test_ln_magnitude.py new file mode 100644 index 000000000..dad1be369 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ln/test_ln_magnitude.py @@ -0,0 +1,217 @@ +"""Tests for $ln core natural-logarithm values across sign and numeric type.""" + +import math + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import DECIMAL128_ZERO, DOUBLE_ZERO + +# Property [Identity]: $ln of one is zero for every numeric type. +LN_ONE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "one_int32", + doc={"value": 1}, + expression={"$ln": ["$value"]}, + expected=DOUBLE_ZERO, + msg="$ln should return zero for int32 one", + ), + ExpressionTestCase( + "one_int64", + doc={"value": Int64(1)}, + expression={"$ln": ["$value"]}, + expected=DOUBLE_ZERO, + msg="$ln should return zero for int64 one", + ), + ExpressionTestCase( + "one_double", + doc={"value": 1.0}, + expression={"$ln": ["$value"]}, + expected=DOUBLE_ZERO, + msg="$ln should return zero for double one", + ), + ExpressionTestCase( + "one_decimal", + doc={"value": Decimal128("1")}, + expression={"$ln": ["$value"]}, + expected=DECIMAL128_ZERO, + msg="$ln should return zero for decimal128 one", + ), +] + +# Property [Value Above One]: $ln returns a positive natural logarithm for inputs greater than one. +LN_ABOVE_ONE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "e_double", + doc={"value": math.e}, + expression={"$ln": ["$value"]}, + expected=pytest.approx(1.0), + msg="$ln should return one for e", + ), + ExpressionTestCase( + "e_squared", + doc={"value": math.e**2}, + expression={"$ln": ["$value"]}, + expected=pytest.approx(2.0), + msg="$ln should return two for e squared", + ), + ExpressionTestCase( + "two", + doc={"value": 2}, + expression={"$ln": ["$value"]}, + expected=pytest.approx(0.6931471805599453), + msg="$ln should return the natural log of two", + ), + ExpressionTestCase( + "ten", + doc={"value": 10}, + expression={"$ln": ["$value"]}, + expected=pytest.approx(2.302585092994046), + msg="$ln should return the natural log of ten", + ), + ExpressionTestCase( + "hundred", + doc={"value": 100}, + expression={"$ln": ["$value"]}, + expected=pytest.approx(4.605170185988092), + msg="$ln should return the natural log of one hundred", + ), + ExpressionTestCase( + "thousand", + doc={"value": 1000}, + expression={"$ln": ["$value"]}, + expected=pytest.approx(6.907755278982137), + msg="$ln should return the natural log of one thousand", + ), + ExpressionTestCase( + "million", + doc={"value": 1_000_000}, + expression={"$ln": ["$value"]}, + expected=pytest.approx(13.815510557964274), + msg="$ln should return the natural log of one million", + ), + ExpressionTestCase( + "billion", + doc={"value": Int64(1_000_000_000)}, + expression={"$ln": ["$value"]}, + expected=pytest.approx(20.72326583694641), + msg="$ln should return the natural log of one billion int64", + ), + ExpressionTestCase( + "one_and_half", + doc={"value": 1.5}, + expression={"$ln": ["$value"]}, + expected=pytest.approx(0.4054651081081644), + msg="$ln should return the natural log of one and a half", + ), + ExpressionTestCase( + "two_and_half", + doc={"value": 2.5}, + expression={"$ln": ["$value"]}, + expected=pytest.approx(0.9162907318741551), + msg="$ln should return the natural log of two and a half", + ), + ExpressionTestCase( + "pi", + doc={"value": math.pi}, + expression={"$ln": ["$value"]}, + expected=pytest.approx(1.1447298858494002), + msg="$ln should return the natural log of pi", + ), +] + +# Property [Value Below One]: $ln returns a negative natural logarithm for inputs between zero and +# one. +LN_BELOW_ONE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "half", + doc={"value": 0.5}, + expression={"$ln": ["$value"]}, + expected=pytest.approx(-0.6931471805599453), + msg="$ln should return the natural log of one half", + ), + ExpressionTestCase( + "tenth", + doc={"value": 0.1}, + expression={"$ln": ["$value"]}, + expected=pytest.approx(-2.302585092994046), + msg="$ln should return the natural log of one tenth", + ), + ExpressionTestCase( + "hundredth", + doc={"value": 0.01}, + expression={"$ln": ["$value"]}, + expected=pytest.approx(-4.605170185988091), + msg="$ln should return the natural log of one hundredth", + ), + ExpressionTestCase( + "thousandth", + doc={"value": 0.001}, + expression={"$ln": ["$value"]}, + expected=pytest.approx(-6.907755278982137), + msg="$ln should return the natural log of one thousandth", + ), + ExpressionTestCase( + "ten_thousandth", + doc={"value": 0.0001}, + expression={"$ln": ["$value"]}, + expected=pytest.approx(-9.210340371976182), + msg="$ln should return the natural log of one ten-thousandth", + ), + ExpressionTestCase( + "billionth", + doc={"value": 0.000000001}, + expression={"$ln": ["$value"]}, + expected=pytest.approx(-20.72326583694641), + msg="$ln should return the natural log of one billionth", + ), + ExpressionTestCase( + "five_billionth", + doc={"value": 0.000000005}, + expression={"$ln": ["$value"]}, + expected=pytest.approx(-19.11382792451231), + msg="$ln should return the natural log of five billionths", + ), +] + +# Property [Decimal Precision]: $ln of a decimal128 input returns a full-precision decimal128. +LN_DECIMAL_PRECISION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "decimal_e", + doc={"value": Decimal128("2.718281828459045")}, + expression={"$ln": ["$value"]}, + expected=Decimal128("0.9999999999999999134157889710887611"), + msg="$ln should return a full-precision decimal128 near one for a decimal128 near e", + ), + ExpressionTestCase( + "decimal_ten", + doc={"value": Decimal128("10")}, + expression={"$ln": ["$value"]}, + expected=Decimal128("2.302585092994045684017991454684364"), + msg="$ln should return a full-precision decimal128 for decimal128 ten", + ), +] + +LN_MAGNITUDE_ALL_TESTS = ( + LN_ONE_TESTS + LN_ABOVE_ONE_TESTS + LN_BELOW_ONE_TESTS + LN_DECIMAL_PRECISION_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(LN_MAGNITUDE_ALL_TESTS)) +def test_ln_magnitude(collection, test_case: ExpressionTestCase): + """Test $ln natural-logarithm value cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ln/test_ln_non_finite.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ln/test_ln_non_finite.py new file mode 100644 index 000000000..10173d6b8 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ln/test_ln_non_finite.py @@ -0,0 +1,68 @@ +"""Tests for $ln of positive infinity and NaN inputs.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + FLOAT_INFINITY, + FLOAT_NAN, +) + +# Property [Infinity]: $ln of positive infinity is infinity of the same type. +LN_INFINITY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "float_infinity", + doc={"value": FLOAT_INFINITY}, + expression={"$ln": ["$value"]}, + expected=FLOAT_INFINITY, + msg="$ln should return infinity for float infinity", + ), + ExpressionTestCase( + "decimal128_infinity", + doc={"value": DECIMAL128_INFINITY}, + expression={"$ln": ["$value"]}, + expected=DECIMAL128_INFINITY, + msg="$ln should return decimal128 infinity for decimal128 infinity", + ), +] + +# Property [NaN]: $ln of NaN returns a double NaN, including for a decimal128 NaN input. +LN_NAN_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "float_nan", + doc={"value": FLOAT_NAN}, + expression={"$ln": ["$value"]}, + expected=pytest.approx(FLOAT_NAN, nan_ok=True), + msg="$ln should return NaN for float NaN", + ), + ExpressionTestCase( + "decimal128_nan", + doc={"value": DECIMAL128_NAN}, + expression={"$ln": ["$value"]}, + expected=pytest.approx(FLOAT_NAN, nan_ok=True), + msg="$ln should return NaN for decimal128 NaN", + ), +] + +LN_NON_FINITE_TESTS = LN_INFINITY_TESTS + LN_NAN_TESTS + + +@pytest.mark.parametrize("test_case", pytest_params(LN_NON_FINITE_TESTS)) +def test_ln_non_finite(collection, test_case: ExpressionTestCase): + """Test $ln positive infinity and NaN cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ln/test_ln_null.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ln/test_ln_null.py new file mode 100644 index 000000000..d7c0ee9e7 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ln/test_ln_null.py @@ -0,0 +1,43 @@ +"""Tests for $ln null and missing field propagation.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import MISSING + +# Property [Null Propagation]: $ln of null or a missing field returns null. +LN_NULL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_value", + doc={"value": None}, + expression={"$ln": ["$value"]}, + expected=None, + msg="$ln should return null for null input", + ), + ExpressionTestCase( + "missing_field", + doc={}, + expression={"$ln": [MISSING]}, + expected=None, + msg="$ln should return null for a missing field", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(LN_NULL_TESTS)) +def test_ln_null(collection, test_case: ExpressionTestCase): + """Test $ln null and missing field propagation cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ln/test_ln_return_type.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ln/test_ln_return_type.py new file mode 100644 index 000000000..22897cd19 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ln/test_ln_return_type.py @@ -0,0 +1,58 @@ +"""Tests for $ln return type across numeric input types.""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Return Type]: $ln returns double for int32, int64, and double inputs, and decimal for +# decimal128 inputs. +LN_RETURN_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "return_type_int32", + doc={"value": 1}, + expression={"$type": {"$ln": "$value"}}, + expected="double", + msg="$ln should return a double for an int32 input", + ), + ExpressionTestCase( + "return_type_int64", + doc={"value": Int64(1)}, + expression={"$type": {"$ln": "$value"}}, + expected="double", + msg="$ln should return a double for an int64 input", + ), + ExpressionTestCase( + "return_type_double", + doc={"value": 1.0}, + expression={"$type": {"$ln": "$value"}}, + expected="double", + msg="$ln should return a double for a double input", + ), + ExpressionTestCase( + "return_type_decimal", + doc={"value": Decimal128("1")}, + expression={"$type": {"$ln": "$value"}}, + expected="decimal", + msg="$ln should return a decimal for a decimal128 input", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(LN_RETURN_TYPE_TESTS)) +def test_ln_return_type(collection, test_case: ExpressionTestCase): + """Test $ln return type cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/log/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/log/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/log/test_log_base.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/log/test_log_base.py new file mode 100644 index 000000000..c08922b02 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/log/test_log_base.py @@ -0,0 +1,119 @@ +"""Tests for $log with non-integer bases, including fractional bases and bases near one.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Fractional Base]: $log with a positive non-integer base returns the correct result, +# negative when the base is below one. +LOG_FRACTIONAL_BASE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "base_half", + doc={"value": 100, "base": 0.5}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(-6.643856189774725), + msg="$log should return the base one half log of one hundred", + ), + ExpressionTestCase( + "base_quarter", + doc={"value": 100, "base": 0.25}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(-3.3219280948873626), + msg="$log should return the base one quarter log of one hundred", + ), + ExpressionTestCase( + "base_1_5", + doc={"value": 100, "base": 1.5}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(11.357747174535147), + msg="$log should return the base one and a half log of one hundred", + ), + ExpressionTestCase( + "base_0_9", + doc={"value": 100, "base": 0.9}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(-43.708690653565675), + msg="$log should return the base nine tenths log of one hundred", + ), + ExpressionTestCase( + "base_0_1", + doc={"value": 100, "base": 0.1}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(-2.0), + msg="$log should return negative two for one hundred in base one tenth", + ), + ExpressionTestCase( + "base_0_01", + doc={"value": 100, "base": 0.01}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(-1.0), + msg="$log should return negative one for one hundred in base one hundredth", + ), +] + +# Property [Base Near One]: $log with a base just above or below one returns a large-magnitude +# result of the corresponding sign. +LOG_BASE_NEAR_ONE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "base_near_one_above", + doc={"value": 10, "base": 1.0001}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(23027.002203302243), + msg="$log should return a large positive result for a base just above one", + ), + ExpressionTestCase( + "base_near_one_below", + doc={"value": 10, "base": 0.9999}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(-23024.699618207327), + msg="$log should return a large negative result for a base just below one", + ), +] + +# Property [Both Below One]: $log with both value and base below one returns a positive result, +# since value and base lie on the same side of one. +LOG_BOTH_BELOW_ONE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "quarter_base_half", + doc={"value": 0.25, "base": 0.5}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(2.0), + msg="$log should return two for one quarter in base one half", + ), + ExpressionTestCase( + "thousandth_base_tenth", + doc={"value": 0.001, "base": 0.1}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(3.0), + msg="$log should return three for one thousandth in base one tenth", + ), + ExpressionTestCase( + "three_tenths_base_half", + doc={"value": 0.3, "base": 0.5}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(1.7369655941662063), + msg="$log should return a positive non-integer result for a non-power value and base " + "both below one", + ), +] + +LOG_BASE_ALL_TESTS = LOG_FRACTIONAL_BASE_TESTS + LOG_BASE_NEAR_ONE_TESTS + LOG_BOTH_BELOW_ONE_TESTS + + +@pytest.mark.parametrize("test_case", pytest_params(LOG_BASE_ALL_TESTS)) +def test_log_base(collection, test_case: ExpressionTestCase): + """Test $log non-integer base cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/log/test_log_boundaries.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/log/test_log_boundaries.py new file mode 100644 index 000000000..8c759cc73 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/log/test_log_boundaries.py @@ -0,0 +1,150 @@ +"""Tests for $log at integer, double, and decimal128 representable-range boundaries.""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_MIN_POSITIVE, + DOUBLE_MIN_SUBNORMAL, + DOUBLE_NEAR_MAX, + DOUBLE_NEAR_MIN, + INT32_MAX, + INT64_MAX, +) + +# Property [Integer Boundaries]: $log of large integers, including the int32 and int64 maxima, +# returns a finite double. +LOG_INTEGER_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "base10_billion", + doc={"value": Int64(1_000_000_000), "base": 10}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(9.0), + msg="$log should return nine for one billion in base ten", + ), + ExpressionTestCase( + "base2_2_to_30", + doc={"value": Int64(1_073_741_824), "base": 2}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(30.0), + msg="$log should return thirty for two to the thirtieth in base two", + ), + ExpressionTestCase( + "base10_max_int32", + doc={"value": INT32_MAX, "base": 10}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(9.331929865381182), + msg="$log should return the base ten log of INT32_MAX", + ), + ExpressionTestCase( + "base10_max_int64", + doc={"value": INT64_MAX, "base": 10}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(18.964889726830812), + msg="$log should return the base ten log of INT64_MAX", + ), + ExpressionTestCase( + "base2_max_int32", + doc={"value": INT32_MAX, "base": 2}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(30.999999999328196), + msg="$log should return the base two log of INT32_MAX", + ), + ExpressionTestCase( + "base2_max_int64", + doc={"value": INT64_MAX, "base": 2}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(63.0), + msg="$log should return the base two log of INT64_MAX", + ), +] + +# Property [Double Boundaries]: $log at the double subnormal and near-limit range, in the value and +# base arguments, returns the expected large or small-magnitude result. +LOG_DOUBLE_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "value_min_subnormal", + doc={"value": DOUBLE_MIN_SUBNORMAL, "base": 10}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(-323.30621534311575), + msg="$log should return a large negative result for the minimum subnormal double value", + ), + ExpressionTestCase( + "value_near_min", + doc={"value": DOUBLE_NEAR_MIN, "base": 10}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(-308.0), + msg="$log should return negative three hundred eight for a near-zero positive double value", + ), + ExpressionTestCase( + "value_near_max", + doc={"value": DOUBLE_NEAR_MAX, "base": 10}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(308.0), + msg="$log should return three hundred eight for a near-maximum double value", + ), + ExpressionTestCase( + "value_very_small_positive", + doc={"value": 1e-300, "base": 10}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(-299.99999999999994), + msg="$log should return a large negative result for a very small positive double value", + ), + ExpressionTestCase( + "base_near_max", + doc={"value": 10, "base": DOUBLE_NEAR_MAX}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(0.003246753246753247), + msg="$log should return a small positive result for a near-maximum double base", + ), +] + +# Property [Decimal Precision]: $log of decimal128 operands returns a full-precision decimal128. +LOG_DECIMAL_PRECISION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "decimal_precision_base10", + doc={"value": Decimal128("1000"), "base": Decimal128("10")}, + expression={"$log": ["$value", "$base"]}, + expected=Decimal128("3.000000000000000000000000000000000"), + msg="$log should return a full-precision decimal128 three for one thousand in base ten", + ), + ExpressionTestCase( + "decimal_precision_base2", + doc={"value": Decimal128("64"), "base": Decimal128("2")}, + expression={"$log": ["$value", "$base"]}, + expected=Decimal128("5.999999999999999999999999999999999"), + msg="$log should return a full-precision decimal128 near six for sixty-four in base two", + ), + ExpressionTestCase( + "decimal_min_positive_value", + doc={"value": DECIMAL128_MIN_POSITIVE, "base": 10}, + expression={"$log": ["$value", "$base"]}, + expected=Decimal128("-6175.999999999999999999999999999999"), + msg="$log should return a full-precision decimal128 near the exponent for the smallest " + "positive decimal128 value in base ten", + ), +] + +LOG_BOUNDARY_ALL_TESTS = ( + LOG_INTEGER_BOUNDARY_TESTS + LOG_DOUBLE_BOUNDARY_TESTS + LOG_DECIMAL_PRECISION_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(LOG_BOUNDARY_ALL_TESTS)) +def test_log_boundaries(collection, test_case: ExpressionTestCase): + """Test $log representable-range boundary cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/log/test_log_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/log/test_log_errors.py new file mode 100644 index 000000000..8b685248c --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/log/test_log_errors.py @@ -0,0 +1,430 @@ +"""Tests for $log domain, type, and arity errors across the value and base arguments.""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + EXPRESSION_TYPE_MISMATCH_ERROR, + LOG_INVALID_BASE_ERROR, + LOG_NON_NUMERIC_BASE_ERROR, + LOG_NON_NUMERIC_VALUE_ERROR, + LOG_NON_POSITIVE_VALUE_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_MAX_NEGATIVE, + DECIMAL128_MIN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ZERO, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_ZERO, + FLOAT_NEGATIVE_INFINITY, + INT32_MIN, + INT64_MIN, +) + +# Property [Value Domain]: $log rejects a non-positive value, including zero, negative zero, +# negative values, and negative infinity, across numeric types. +LOG_VALUE_DOMAIN_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "zero_value", + doc={"value": 0, "base": 10}, + expression={"$log": ["$value", "$base"]}, + error_code=LOG_NON_POSITIVE_VALUE_ERROR, + msg="$log should reject an int32 zero value", + ), + ExpressionTestCase( + "zero_double_value", + doc={"value": DOUBLE_ZERO, "base": 10}, + expression={"$log": ["$value", "$base"]}, + error_code=LOG_NON_POSITIVE_VALUE_ERROR, + msg="$log should reject a double zero value", + ), + ExpressionTestCase( + "negative_zero_double_value", + doc={"value": DOUBLE_NEGATIVE_ZERO, "base": 10}, + expression={"$log": ["$value", "$base"]}, + error_code=LOG_NON_POSITIVE_VALUE_ERROR, + msg="$log should reject a negative zero double value", + ), + ExpressionTestCase( + "zero_decimal_value", + doc={"value": DECIMAL128_ZERO, "base": 10}, + expression={"$log": ["$value", "$base"]}, + error_code=LOG_NON_POSITIVE_VALUE_ERROR, + msg="$log should reject a decimal128 zero value", + ), + ExpressionTestCase( + "negative_zero_decimal_value", + doc={"value": DECIMAL128_NEGATIVE_ZERO, "base": 10}, + expression={"$log": ["$value", "$base"]}, + error_code=LOG_NON_POSITIVE_VALUE_ERROR, + msg="$log should reject a negative zero decimal128 value", + ), + ExpressionTestCase( + "negative_value", + doc={"value": -10, "base": 10}, + expression={"$log": ["$value", "$base"]}, + error_code=LOG_NON_POSITIVE_VALUE_ERROR, + msg="$log should reject a negative int32 value", + ), + ExpressionTestCase( + "negative_fractional_value", + doc={"value": -0.5, "base": 10}, + expression={"$log": ["$value", "$base"]}, + error_code=LOG_NON_POSITIVE_VALUE_ERROR, + msg="$log should reject a negative fractional value", + ), + ExpressionTestCase( + "negative_decimal_value", + doc={"value": Decimal128("-5"), "base": 10}, + expression={"$log": ["$value", "$base"]}, + error_code=LOG_NON_POSITIVE_VALUE_ERROR, + msg="$log should reject a negative decimal128 value", + ), + ExpressionTestCase( + "int32_min_value", + doc={"value": INT32_MIN, "base": 10}, + expression={"$log": ["$value", "$base"]}, + error_code=LOG_NON_POSITIVE_VALUE_ERROR, + msg="$log should reject an INT32_MIN value", + ), + ExpressionTestCase( + "int64_min_value", + doc={"value": INT64_MIN, "base": 10}, + expression={"$log": ["$value", "$base"]}, + error_code=LOG_NON_POSITIVE_VALUE_ERROR, + msg="$log should reject an INT64_MIN value", + ), + ExpressionTestCase( + "decimal_min_value", + doc={"value": DECIMAL128_MIN, "base": 10}, + expression={"$log": ["$value", "$base"]}, + error_code=LOG_NON_POSITIVE_VALUE_ERROR, + msg="$log should reject the minimum decimal128 value", + ), + ExpressionTestCase( + "decimal_max_negative_value", + doc={"value": DECIMAL128_MAX_NEGATIVE, "base": 10}, + expression={"$log": ["$value", "$base"]}, + error_code=LOG_NON_POSITIVE_VALUE_ERROR, + msg="$log should reject the smallest-magnitude negative decimal128 value", + ), + ExpressionTestCase( + "negative_infinity_value", + doc={"value": FLOAT_NEGATIVE_INFINITY, "base": 10}, + expression={"$log": ["$value", "$base"]}, + error_code=LOG_NON_POSITIVE_VALUE_ERROR, + msg="$log should reject a float negative infinity value", + ), + ExpressionTestCase( + "decimal_negative_infinity_value", + doc={"value": DECIMAL128_NEGATIVE_INFINITY, "base": 10}, + expression={"$log": ["$value", "$base"]}, + error_code=LOG_NON_POSITIVE_VALUE_ERROR, + msg="$log should reject a decimal128 negative infinity value", + ), +] + +# Property [Base Domain]: $log rejects a base that is not a positive number other than one, +# including zero, negative bases, negative infinity, and one. +LOG_BASE_DOMAIN_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "zero_base", + doc={"value": 100, "base": 0}, + expression={"$log": ["$value", "$base"]}, + error_code=LOG_INVALID_BASE_ERROR, + msg="$log should reject an int32 zero base", + ), + ExpressionTestCase( + "zero_double_base", + doc={"value": 100, "base": DOUBLE_ZERO}, + expression={"$log": ["$value", "$base"]}, + error_code=LOG_INVALID_BASE_ERROR, + msg="$log should reject a double zero base", + ), + ExpressionTestCase( + "negative_zero_double_base", + doc={"value": 100, "base": DOUBLE_NEGATIVE_ZERO}, + expression={"$log": ["$value", "$base"]}, + error_code=LOG_INVALID_BASE_ERROR, + msg="$log should reject a negative zero double base", + ), + ExpressionTestCase( + "zero_decimal_base", + doc={"value": 100, "base": DECIMAL128_ZERO}, + expression={"$log": ["$value", "$base"]}, + error_code=LOG_INVALID_BASE_ERROR, + msg="$log should reject a decimal128 zero base", + ), + ExpressionTestCase( + "negative_zero_decimal_base", + doc={"value": 100, "base": DECIMAL128_NEGATIVE_ZERO}, + expression={"$log": ["$value", "$base"]}, + error_code=LOG_INVALID_BASE_ERROR, + msg="$log should reject a negative zero decimal128 base", + ), + ExpressionTestCase( + "negative_base", + doc={"value": 100, "base": -10}, + expression={"$log": ["$value", "$base"]}, + error_code=LOG_INVALID_BASE_ERROR, + msg="$log should reject a negative int32 base", + ), + ExpressionTestCase( + "negative_base_two", + doc={"value": 100, "base": -2}, + expression={"$log": ["$value", "$base"]}, + error_code=LOG_INVALID_BASE_ERROR, + msg="$log should reject a negative int32 base of minus two", + ), + ExpressionTestCase( + "negative_decimal_base", + doc={"value": 100, "base": Decimal128("-2")}, + expression={"$log": ["$value", "$base"]}, + error_code=LOG_INVALID_BASE_ERROR, + msg="$log should reject a negative decimal128 base", + ), + ExpressionTestCase( + "int32_min_base", + doc={"value": 100, "base": INT32_MIN}, + expression={"$log": ["$value", "$base"]}, + error_code=LOG_INVALID_BASE_ERROR, + msg="$log should reject an INT32_MIN base", + ), + ExpressionTestCase( + "int64_min_base", + doc={"value": 100, "base": INT64_MIN}, + expression={"$log": ["$value", "$base"]}, + error_code=LOG_INVALID_BASE_ERROR, + msg="$log should reject an INT64_MIN base", + ), + ExpressionTestCase( + "decimal_min_base", + doc={"value": 100, "base": DECIMAL128_MIN}, + expression={"$log": ["$value", "$base"]}, + error_code=LOG_INVALID_BASE_ERROR, + msg="$log should reject the minimum decimal128 base", + ), + ExpressionTestCase( + "negative_infinity_base", + doc={"value": 100, "base": FLOAT_NEGATIVE_INFINITY}, + expression={"$log": ["$value", "$base"]}, + error_code=LOG_INVALID_BASE_ERROR, + msg="$log should reject a float negative infinity base", + ), + ExpressionTestCase( + "decimal_negative_infinity_base", + doc={"value": 100, "base": DECIMAL128_NEGATIVE_INFINITY}, + expression={"$log": ["$value", "$base"]}, + error_code=LOG_INVALID_BASE_ERROR, + msg="$log should reject a decimal128 negative infinity base", + ), + ExpressionTestCase( + "base_one", + doc={"value": 100, "base": 1}, + expression={"$log": ["$value", "$base"]}, + error_code=LOG_INVALID_BASE_ERROR, + msg="$log should reject an int32 base of one", + ), + ExpressionTestCase( + "base_one_double", + doc={"value": 100, "base": 1.0}, + expression={"$log": ["$value", "$base"]}, + error_code=LOG_INVALID_BASE_ERROR, + msg="$log should reject a double base of one", + ), + ExpressionTestCase( + "base_one_decimal", + doc={"value": 100, "base": Decimal128("1")}, + expression={"$log": ["$value", "$base"]}, + error_code=LOG_INVALID_BASE_ERROR, + msg="$log should reject a decimal128 base of one", + ), +] + +# Property [Value Type Strictness]: $log rejects a non-numeric value. +LOG_VALUE_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + *[ + ExpressionTestCase( + f"value_type_{tid}", + doc={"value": val, "base": 10}, + expression={"$log": ["$value", "$base"]}, + error_code=LOG_NON_NUMERIC_VALUE_ERROR, + msg=f"$log should reject a {tid} value", + ) + for tid, val in [ + ("string", "abc"), + ("bool", True), + ("array", [1, 2]), + ("object", {"a": 1}), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("regex", Regex("abc")), + ("binary", Binary(b"data")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("timestamp", Timestamp(1, 1)), + ("code", Code("function(){}")), + ] + ], + ExpressionTestCase( + "value_empty_array", + doc={"value": [], "base": 10}, + expression={"$log": ["$value", "$base"]}, + error_code=LOG_NON_NUMERIC_VALUE_ERROR, + msg="$log should reject an empty array value", + ), + ExpressionTestCase( + "value_empty_object", + doc={"value": {}, "base": 10}, + expression={"$log": ["$value", "$base"]}, + error_code=LOG_NON_NUMERIC_VALUE_ERROR, + msg="$log should reject an empty object value", + ), +] + +# Property [Base Type Strictness]: $log rejects a non-numeric base. +LOG_BASE_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + *[ + ExpressionTestCase( + f"base_type_{tid}", + doc={"value": 100, "base": val}, + expression={"$log": ["$value", "$base"]}, + error_code=LOG_NON_NUMERIC_BASE_ERROR, + msg=f"$log should reject a {tid} base", + ) + for tid, val in [ + ("string", "abc"), + ("bool", True), + ("array", [1, 2]), + ("object", {"a": 1}), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("regex", Regex("abc")), + ("binary", Binary(b"data")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("timestamp", Timestamp(1, 1)), + ("code", Code("function(){}")), + ] + ], + ExpressionTestCase( + "base_empty_array", + doc={"value": 100, "base": []}, + expression={"$log": ["$value", "$base"]}, + error_code=LOG_NON_NUMERIC_BASE_ERROR, + msg="$log should reject an empty array base", + ), + ExpressionTestCase( + "base_empty_object", + doc={"value": 100, "base": {}}, + expression={"$log": ["$value", "$base"]}, + error_code=LOG_NON_NUMERIC_BASE_ERROR, + msg="$log should reject an empty object base", + ), +] + +# Property [Non-Numeric Expression and Path Inputs]: array and object expression inputs, and field +# paths that resolve to an array, are delivered to $log as values and rejected as non-numeric in +# the value and base arguments. +LOG_EXPRESSION_INPUT_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "array_value_expression", + doc={"value": 100}, + expression={"$log": [["$value"], 10]}, + error_code=LOG_NON_NUMERIC_VALUE_ERROR, + msg="$log should reject an array expression value without flattening it into arguments", + ), + ExpressionTestCase( + "object_value_expression", + doc={"value": 100}, + expression={"$log": [{"z": "$value"}, 10]}, + error_code=LOG_NON_NUMERIC_VALUE_ERROR, + msg="$log should reject an object expression value rather than evaluating it", + ), + ExpressionTestCase( + "array_path_value", + doc={"a": [{"b": 10}, {"b": 100}]}, + expression={"$log": ["$a.b", 10]}, + error_code=LOG_NON_NUMERIC_VALUE_ERROR, + msg="$log should reject a field-path value that resolves to an array", + ), + ExpressionTestCase( + "array_base_expression", + doc={"base": 10}, + expression={"$log": [100, ["$base"]]}, + error_code=LOG_NON_NUMERIC_BASE_ERROR, + msg="$log should reject an array expression base without flattening it into arguments", + ), + ExpressionTestCase( + "object_base_expression", + doc={"base": 10}, + expression={"$log": [100, {"z": "$base"}]}, + error_code=LOG_NON_NUMERIC_BASE_ERROR, + msg="$log should reject an object expression base rather than evaluating it", + ), + ExpressionTestCase( + "array_path_base", + doc={"a": [{"b": 10}, {"b": 100}]}, + expression={"$log": [100, "$a.b"]}, + error_code=LOG_NON_NUMERIC_BASE_ERROR, + msg="$log should reject a field-path base that resolves to an array", + ), +] + +# Property [Arity]: $log requires exactly two arguments. +LOG_ARITY_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "arity_zero", + doc={}, + expression={"$log": []}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="$log should reject zero arguments", + ), + ExpressionTestCase( + "arity_one", + doc={}, + expression={"$log": [100]}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="$log should reject a single argument", + ), + ExpressionTestCase( + "arity_three", + doc={}, + expression={"$log": [100, 10, 2]}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="$log should reject three arguments", + ), +] + +LOG_ERROR_ALL_TESTS = ( + LOG_VALUE_DOMAIN_ERROR_TESTS + + LOG_BASE_DOMAIN_ERROR_TESTS + + LOG_VALUE_TYPE_ERROR_TESTS + + LOG_BASE_TYPE_ERROR_TESTS + + LOG_EXPRESSION_INPUT_ERROR_TESTS + + LOG_ARITY_ERROR_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(LOG_ERROR_ALL_TESTS)) +def test_log_errors(collection, test_case: ExpressionTestCase): + """Test $log domain, type, and arity error cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/log/test_log_input_forms.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/log/test_log_input_forms.py new file mode 100644 index 000000000..bdff51615 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/log/test_log_input_forms.py @@ -0,0 +1,70 @@ +"""Tests for $log argument form, literal input, and nested expression input.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import EXPRESSION_TYPE_MISMATCH_ERROR +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Argument Form]: $log requires its two arguments in an array and rejects a bare +# non-array argument. +LOG_ARGUMENT_FORM_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "bare_non_array", + doc={"value": 100}, + expression={"$log": "$value"}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="$log should reject a bare non-array argument, requiring its value and base in an " + "array", + ), +] + +# Property [Literal Input]: $log evaluates inline literal value and base arguments. +LOG_LITERAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "literal_input", + doc={}, + expression={"$log": [100, 10]}, + expected=pytest.approx(2.0), + msg="$log should return two for inline literal one hundred in base ten", + ), +] + +# Property [Expression Input]: $log evaluates a nested expression in the value or base argument +# before taking the logarithm. +LOG_EXPRESSION_INPUT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "expression_value", + doc={}, + expression={"$log": [{"$add": [50, 50]}, 10]}, + expected=pytest.approx(2.0), + msg="$log should evaluate a nested expression value argument", + ), + ExpressionTestCase( + "expression_base", + doc={}, + expression={"$log": [100, {"$add": [5, 5]}]}, + expected=pytest.approx(2.0), + msg="$log should evaluate a nested expression base argument", + ), +] + +LOG_INPUT_FORM_TESTS = LOG_ARGUMENT_FORM_TESTS + LOG_LITERAL_TESTS + LOG_EXPRESSION_INPUT_TESTS + + +@pytest.mark.parametrize("test_case", pytest_params(LOG_INPUT_FORM_TESTS)) +def test_log_input_forms(collection, test_case: ExpressionTestCase): + """Test $log argument form, literal, and nested expression input cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/log/test_log_magnitude.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/log/test_log_magnitude.py new file mode 100644 index 000000000..eb1f82e77 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/log/test_log_magnitude.py @@ -0,0 +1,417 @@ +"""Tests for $log core logarithm values across base, sign, and numeric type.""" + +import math + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import DOUBLE_ZERO + +# Property [Identity]: $log of one is zero for any base and numeric type. +LOG_IDENTITY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "one_base10", + doc={"value": 1, "base": 10}, + expression={"$log": ["$value", "$base"]}, + expected=DOUBLE_ZERO, + msg="$log should return zero for one in base ten", + ), + ExpressionTestCase( + "one_base2", + doc={"value": 1, "base": 2}, + expression={"$log": ["$value", "$base"]}, + expected=DOUBLE_ZERO, + msg="$log should return zero for one in base two", + ), + ExpressionTestCase( + "one_base5", + doc={"value": 1, "base": 5}, + expression={"$log": ["$value", "$base"]}, + expected=DOUBLE_ZERO, + msg="$log should return zero for one in base five", + ), +] + +# Property [Base Equals Value]: $log of a value in its own base is one. +LOG_BASE_EQUALS_VALUE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "same_ten", + doc={"value": 10, "base": 10}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(1.0), + msg="$log should return one when value equals base ten", + ), + ExpressionTestCase( + "same_two", + doc={"value": 2, "base": 2}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(1.0), + msg="$log should return one when value equals base two", + ), + ExpressionTestCase( + "same_five", + doc={"value": 5, "base": 5}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(1.0), + msg="$log should return one when value equals base five", + ), +] + +# Property [Same Type]: $log of same-typed value and base returns the correct result per type. +LOG_SAME_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "base10_hundred_int32", + doc={"value": 100, "base": 10}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(2.0), + msg="$log should return two for one hundred in base ten int32", + ), + ExpressionTestCase( + "base10_thousand_int64", + doc={"value": Int64(1000), "base": Int64(10)}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(3.0), + msg="$log should return three for one thousand in base ten int64", + ), + ExpressionTestCase( + "base10_ten_double", + doc={"value": 10.0, "base": 10.0}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(1.0), + msg="$log should return one for ten in base ten double", + ), + ExpressionTestCase( + "base10_hundred_decimal", + doc={"value": Decimal128("100"), "base": Decimal128("10")}, + expression={"$log": ["$value", "$base"]}, + expected=Decimal128("2"), + msg="$log should return two for one hundred in base ten decimal128", + ), + ExpressionTestCase( + "base2_eight_int32", + doc={"value": 8, "base": 2}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(3.0), + msg="$log should return three for eight in base two int32", + ), + ExpressionTestCase( + "base2_1024_int64", + doc={"value": Int64(1024), "base": Int64(2)}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(10.0), + msg="$log should return ten for 1024 in base two int64", + ), + ExpressionTestCase( + "base2_sixteen_double", + doc={"value": 16.0, "base": 2.0}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(4.0), + msg="$log should return four for sixteen in base two double", + ), + ExpressionTestCase( + "base2_32_decimal", + doc={"value": Decimal128("32"), "base": Decimal128("2")}, + expression={"$log": ["$value", "$base"]}, + expected=Decimal128("5"), + msg="$log should return five for thirty-two in base two decimal128", + ), +] + +# Property [Mixed Type]: $log of mixed numeric types returns decimal128 when either operand is +# decimal128 and double otherwise. +LOG_MIXED_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int32_int64", + doc={"value": 100, "base": Int64(10)}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(2.0), + msg="$log should return two for int32 value and int64 base", + ), + ExpressionTestCase( + "int32_double", + doc={"value": 100, "base": 10.0}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(2.0), + msg="$log should return two for int32 value and double base", + ), + ExpressionTestCase( + "int32_decimal", + doc={"value": 100, "base": Decimal128("10")}, + expression={"$log": ["$value", "$base"]}, + expected=Decimal128("2"), + msg="$log should return decimal128 two for int32 value and decimal128 base", + ), + ExpressionTestCase( + "int64_double", + doc={"value": Int64(1000), "base": 10.0}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(3.0), + msg="$log should return three for int64 value and double base", + ), + ExpressionTestCase( + "int64_decimal", + doc={"value": Int64(1000), "base": Decimal128("10")}, + expression={"$log": ["$value", "$base"]}, + expected=Decimal128("3.000000000000000000000000000000000"), + msg="$log should return full-precision decimal128 three for int64 and decimal128 base", + ), + ExpressionTestCase( + "double_decimal", + doc={"value": 100.0, "base": Decimal128("10")}, + expression={"$log": ["$value", "$base"]}, + expected=Decimal128("2"), + msg="$log should return decimal128 two for double value and decimal128 base", + ), +] + +# Property [Powers Of Base]: $log of an exact power of the base returns the integer exponent. +LOG_POWER_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "base10_ten_thousand", + doc={"value": 10_000, "base": 10}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(4.0), + msg="$log should return four for ten thousand in base ten", + ), + ExpressionTestCase( + "base10_million", + doc={"value": 1_000_000, "base": 10}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(6.0), + msg="$log should return six for one million in base ten", + ), + ExpressionTestCase( + "base2_64", + doc={"value": 64, "base": 2}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(6.0), + msg="$log should return six for sixty-four in base two", + ), + ExpressionTestCase( + "base2_128", + doc={"value": 128, "base": 2}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(7.0), + msg="$log should return seven for one hundred twenty-eight in base two", + ), + ExpressionTestCase( + "base2_256", + doc={"value": 256, "base": 2}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(8.0), + msg="$log should return eight for two hundred fifty-six in base two", + ), + ExpressionTestCase( + "base5_125", + doc={"value": 125, "base": 5}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(3.0), + msg="$log should return three for one hundred twenty-five in base five", + ), + ExpressionTestCase( + "base5_625", + doc={"value": 625, "base": 5}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(4.0), + msg="$log should return four for six hundred twenty-five in base five", + ), + ExpressionTestCase( + "base3_27", + doc={"value": 27, "base": 3}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(3.0), + msg="$log should return three for twenty-seven in base three", + ), + ExpressionTestCase( + "base3_81", + doc={"value": 81, "base": 3}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(4.0), + msg="$log should return four for eighty-one in base three", + ), +] + +# Property [Fractional Result]: $log of a non-power value returns the correct non-integer result. +LOG_FRACTIONAL_RESULT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "base10_50", + doc={"value": 50, "base": 10}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(1.6989700043360185), + msg="$log should return the base ten log of fifty", + ), + ExpressionTestCase( + "base10_5", + doc={"value": 5, "base": 10}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(0.6989700043360187), + msg="$log should return the base ten log of five", + ), + ExpressionTestCase( + "base2_3", + doc={"value": 3, "base": 2}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(1.5849625007211563), + msg="$log should return the base two log of three", + ), + ExpressionTestCase( + "base2_10", + doc={"value": 10, "base": 2}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(3.3219280948873626), + msg="$log should return the base two log of ten", + ), + ExpressionTestCase( + "base3_100", + doc={"value": 100, "base": 3}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(4.19180654857877), + msg="$log should return the base three log of one hundred", + ), + ExpressionTestCase( + "base10_e", + doc={"value": math.e, "base": 10}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(0.43429448190325176), + msg="$log should return the base ten log of e", + ), + ExpressionTestCase( + "base10_pi", + doc={"value": math.pi, "base": 10}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(0.4971498726941338), + msg="$log should return the base ten log of pi", + ), + ExpressionTestCase( + "base2_e", + doc={"value": math.e, "base": 2}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(1.4426950408889634), + msg="$log should return the base two log of e", + ), + ExpressionTestCase( + "base2_pi", + doc={"value": math.pi, "base": 2}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(1.651496129472319), + msg="$log should return the base two log of pi", + ), + ExpressionTestCase( + "base_e_e", + doc={"value": math.e, "base": math.e}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(1.0), + msg="$log should return one for e in base e", + ), + ExpressionTestCase( + "base_e_e_squared", + doc={"value": math.e**2, "base": math.e}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(2.0), + msg="$log should return two for e squared in base e", + ), + ExpressionTestCase( + "base2_hundred", + doc={"value": 100, "base": 2}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(6.643856189774725), + msg="$log should return the base two log of one hundred", + ), + ExpressionTestCase( + "base5_hundred", + doc={"value": 100, "base": 5}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(2.8613531161467867), + msg="$log should return the base five log of one hundred", + ), + ExpressionTestCase( + "base2_thousand", + doc={"value": 1000, "base": 2}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(9.965784284662087), + msg="$log should return the base two log of one thousand", + ), +] + +# Property [Fractional Value]: $log of a value between zero and one returns a negative result. +LOG_FRACTIONAL_VALUE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "base10_tenth", + doc={"value": 0.1, "base": 10}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(-1.0), + msg="$log should return negative one for one tenth in base ten", + ), + ExpressionTestCase( + "base10_hundredth", + doc={"value": 0.01, "base": 10}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(-2.0), + msg="$log should return negative two for one hundredth in base ten", + ), + ExpressionTestCase( + "base2_half", + doc={"value": 0.5, "base": 2}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(-1.0), + msg="$log should return negative one for one half in base two", + ), + ExpressionTestCase( + "base2_quarter", + doc={"value": 0.25, "base": 2}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(-2.0), + msg="$log should return negative two for one quarter in base two", + ), + ExpressionTestCase( + "base2_eighth", + doc={"value": 0.125, "base": 2}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(-3.0), + msg="$log should return negative three for one eighth in base two", + ), + ExpressionTestCase( + "base10_billionth", + doc={"value": 0.000000001, "base": 10}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(-9.0), + msg="$log should return negative nine for one billionth in base ten", + ), + ExpressionTestCase( + "base10_five_billionth", + doc={"value": 0.000000005, "base": 10}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(-8.301029995663981), + msg="$log should return the base ten log of five billionths", + ), +] + +LOG_MAGNITUDE_ALL_TESTS = ( + LOG_IDENTITY_TESTS + + LOG_BASE_EQUALS_VALUE_TESTS + + LOG_SAME_TYPE_TESTS + + LOG_MIXED_TYPE_TESTS + + LOG_POWER_TESTS + + LOG_FRACTIONAL_RESULT_TESTS + + LOG_FRACTIONAL_VALUE_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(LOG_MAGNITUDE_ALL_TESTS)) +def test_log_magnitude(collection, test_case: ExpressionTestCase): + """Test $log logarithm value cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/log/test_log_non_finite.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/log/test_log_non_finite.py new file mode 100644 index 000000000..6fc7c8d53 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/log/test_log_non_finite.py @@ -0,0 +1,127 @@ +"""Tests for $log with infinite and NaN value and base inputs.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DOUBLE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, +) + +# Property [Infinity]: $log of infinity is infinity, $log to an infinite base is zero, and infinity +# in both operands is NaN. +LOG_INFINITY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "infinity_value", + doc={"value": FLOAT_INFINITY, "base": 10}, + expression={"$log": ["$value", "$base"]}, + expected=FLOAT_INFINITY, + msg="$log should return infinity for an infinite value in base ten", + ), + ExpressionTestCase( + "infinity_value_base2", + doc={"value": FLOAT_INFINITY, "base": 2}, + expression={"$log": ["$value", "$base"]}, + expected=FLOAT_INFINITY, + msg="$log should return infinity for an infinite value in base two", + ), + ExpressionTestCase( + "decimal_infinity_value", + doc={"value": DECIMAL128_INFINITY, "base": 10}, + expression={"$log": ["$value", "$base"]}, + expected=DECIMAL128_INFINITY, + msg="$log should return decimal128 infinity for an infinite decimal128 value", + ), + ExpressionTestCase( + "infinity_base", + doc={"value": 10, "base": FLOAT_INFINITY}, + expression={"$log": ["$value", "$base"]}, + expected=DOUBLE_ZERO, + msg="$log should return zero for a finite value in an infinite base", + ), + ExpressionTestCase( + "both_infinity", + doc={"value": FLOAT_INFINITY, "base": FLOAT_INFINITY}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(FLOAT_NAN, nan_ok=True), + msg="$log should return NaN when both value and base are infinite", + ), + ExpressionTestCase( + "decimal_both_infinity", + doc={"value": DECIMAL128_INFINITY, "base": DECIMAL128_INFINITY}, + expression={"$log": ["$value", "$base"]}, + expected=DECIMAL128_NAN, + msg="$log should return decimal128 NaN when both value and base are infinite decimal128", + ), +] + +# Property [NaN]: $log of a NaN value or base returns a double NaN, including for decimal128 NaN +# inputs. +LOG_NAN_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nan_value", + doc={"value": FLOAT_NAN, "base": 10}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(FLOAT_NAN, nan_ok=True), + msg="$log should return NaN for a NaN value", + ), + ExpressionTestCase( + "nan_base", + doc={"value": 100, "base": FLOAT_NAN}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(FLOAT_NAN, nan_ok=True), + msg="$log should return NaN for a NaN base", + ), + ExpressionTestCase( + "both_nan", + doc={"value": FLOAT_NAN, "base": FLOAT_NAN}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(FLOAT_NAN, nan_ok=True), + msg="$log should return NaN when both value and base are NaN", + ), + ExpressionTestCase( + "decimal_nan_value", + doc={"value": DECIMAL128_NAN, "base": 10}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(FLOAT_NAN, nan_ok=True), + msg="$log should return NaN for a decimal128 NaN value", + ), + ExpressionTestCase( + "decimal_nan_base", + doc={"value": 100, "base": DECIMAL128_NAN}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(FLOAT_NAN, nan_ok=True), + msg="$log should return NaN for a decimal128 NaN base", + ), + ExpressionTestCase( + "decimal_both_nan", + doc={"value": DECIMAL128_NAN, "base": DECIMAL128_NAN}, + expression={"$log": ["$value", "$base"]}, + expected=pytest.approx(FLOAT_NAN, nan_ok=True), + msg="$log should return NaN when both value and base are decimal128 NaN", + ), +] + +LOG_NON_FINITE_TESTS = LOG_INFINITY_TESTS + LOG_NAN_TESTS + + +@pytest.mark.parametrize("test_case", pytest_params(LOG_NON_FINITE_TESTS)) +def test_log_non_finite(collection, test_case: ExpressionTestCase): + """Test $log infinity and NaN cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/log/test_log_null.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/log/test_log_null.py new file mode 100644 index 000000000..3d383e791 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/log/test_log_null.py @@ -0,0 +1,86 @@ +"""Tests for $log null and missing propagation across the value and base arguments.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import MISSING + +# Property [Null Propagation]: $log returns null when either the value or the base is null or a +# missing field. +LOG_NULL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_value", + doc={"value": None, "base": 10}, + expression={"$log": ["$value", "$base"]}, + expected=None, + msg="$log should return null for a null value", + ), + ExpressionTestCase( + "null_base", + doc={"value": 10, "base": None}, + expression={"$log": ["$value", "$base"]}, + expected=None, + msg="$log should return null for a null base", + ), + ExpressionTestCase( + "both_null", + doc={"value": None, "base": None}, + expression={"$log": ["$value", "$base"]}, + expected=None, + msg="$log should return null when both value and base are null", + ), + ExpressionTestCase( + "missing_value", + doc={"base": 10}, + expression={"$log": [MISSING, "$base"]}, + expected=None, + msg="$log should return null for a missing value field", + ), + ExpressionTestCase( + "missing_base", + doc={"value": 10}, + expression={"$log": ["$value", MISSING]}, + expected=None, + msg="$log should return null for a missing base field", + ), + ExpressionTestCase( + "both_missing", + doc={}, + expression={"$log": [MISSING, MISSING]}, + expected=None, + msg="$log should return null when both value and base fields are missing", + ), + ExpressionTestCase( + "null_value_missing_base", + doc={"value": None}, + expression={"$log": ["$value", MISSING]}, + expected=None, + msg="$log should return null when the value is null and the base is missing", + ), + ExpressionTestCase( + "missing_value_null_base", + doc={"base": None}, + expression={"$log": [MISSING, "$base"]}, + expected=None, + msg="$log should return null when the value is missing and the base is null", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(LOG_NULL_TESTS)) +def test_log_null(collection, test_case: ExpressionTestCase): + """Test $log null and missing propagation cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/log/test_log_return_type.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/log/test_log_return_type.py new file mode 100644 index 000000000..6699dac85 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/log/test_log_return_type.py @@ -0,0 +1,72 @@ +"""Tests for $log return type across value and base numeric types.""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Return Type]: $log returns double unless either operand is decimal128, in which case it +# returns decimal. +LOG_RETURN_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "return_type_int32", + doc={"value": 100, "base": 10}, + expression={"$type": {"$log": ["$value", "$base"]}}, + expected="double", + msg="$log should return a double for int32 value and base", + ), + ExpressionTestCase( + "return_type_int64", + doc={"value": Int64(1000), "base": Int64(10)}, + expression={"$type": {"$log": ["$value", "$base"]}}, + expected="double", + msg="$log should return a double for int64 value and base", + ), + ExpressionTestCase( + "return_type_double", + doc={"value": 10.0, "base": 10.0}, + expression={"$type": {"$log": ["$value", "$base"]}}, + expected="double", + msg="$log should return a double for double value and base", + ), + ExpressionTestCase( + "return_type_value_decimal", + doc={"value": Decimal128("100"), "base": 10}, + expression={"$type": {"$log": ["$value", "$base"]}}, + expected="decimal", + msg="$log should return a decimal for a decimal128 value and int32 base", + ), + ExpressionTestCase( + "return_type_base_decimal", + doc={"value": 100, "base": Decimal128("10")}, + expression={"$type": {"$log": ["$value", "$base"]}}, + expected="decimal", + msg="$log should return a decimal for an int32 value and decimal128 base", + ), + ExpressionTestCase( + "return_type_both_decimal", + doc={"value": Decimal128("100"), "base": Decimal128("10")}, + expression={"$type": {"$log": ["$value", "$base"]}}, + expected="decimal", + msg="$log should return a decimal for decimal128 value and base", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(LOG_RETURN_TYPE_TESTS)) +def test_log_return_type(collection, test_case: ExpressionTestCase): + """Test $log return type cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/log10/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/log10/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/log10/test_log10_boundaries.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/log10/test_log10_boundaries.py new file mode 100644 index 000000000..cb3599746 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/log10/test_log10_boundaries.py @@ -0,0 +1,160 @@ +"""Tests for $log10 at representable-range boundaries, including subnormal and extreme inputs.""" + +import pytest +from bson import Decimal128 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_MAX, + DECIMAL128_MIN_POSITIVE, + DECIMAL128_SMALL_EXPONENT, + DECIMAL128_TRAILING_ZERO, + DECIMAL128_ZERO, + DOUBLE_MAX_SAFE_INTEGER, + DOUBLE_MIN_SUBNORMAL, + DOUBLE_NEAR_MAX, + DOUBLE_NEAR_MIN, + DOUBLE_PRECISION_LOSS, + INT32_MAX, + INT32_MAX_MINUS_1, + INT64_MAX, + INT64_MAX_MINUS_1, +) + +# Property [Integer Boundaries]: $log10 of the largest representable integers returns a finite +# double. +LOG10_INTEGER_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int32_max", + doc={"value": INT32_MAX}, + expression={"$log10": ["$value"]}, + expected=pytest.approx(9.331929865381182), + msg="$log10 should return the base-ten log of INT32_MAX", + ), + ExpressionTestCase( + "int32_max_minus_1", + doc={"value": INT32_MAX_MINUS_1}, + expression={"$log10": ["$value"]}, + expected=pytest.approx(9.33192986517895), + msg="$log10 should return the base-ten log of INT32_MAX minus one", + ), + ExpressionTestCase( + "int64_max", + doc={"value": INT64_MAX}, + expression={"$log10": ["$value"]}, + expected=pytest.approx(18.964889726830815), + msg="$log10 should return the base-ten log of INT64_MAX", + ), + ExpressionTestCase( + "int64_max_minus_1", + doc={"value": INT64_MAX_MINUS_1}, + expression={"$log10": ["$value"]}, + expected=pytest.approx(18.964889726830815), + msg="$log10 should return the base-ten log of INT64_MAX minus one", + ), +] + +# Property [Double Boundaries]: $log10 at the double subnormal and near-limit range returns the +# expected large-magnitude values. +LOG10_DOUBLE_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "double_min_subnormal", + doc={"value": DOUBLE_MIN_SUBNORMAL}, + expression={"$log10": ["$value"]}, + expected=pytest.approx(-323.3062153431158), + msg="$log10 should return a large negative value for the minimum subnormal double", + ), + ExpressionTestCase( + "double_near_min", + doc={"value": DOUBLE_NEAR_MIN}, + expression={"$log10": ["$value"]}, + expected=-308.0, + msg="$log10 should return negative three hundred eight for a near-zero positive double", + ), + ExpressionTestCase( + "double_near_max", + doc={"value": DOUBLE_NEAR_MAX}, + expression={"$log10": ["$value"]}, + expected=308.0, + msg="$log10 should return three hundred eight for a near-maximum double", + ), + ExpressionTestCase( + "double_max_safe_integer", + doc={"value": DOUBLE_MAX_SAFE_INTEGER}, + expression={"$log10": ["$value"]}, + expected=pytest.approx(15.954589770191003), + msg="$log10 should return the base-ten log of the maximum safe integer double", + ), + ExpressionTestCase( + "double_precision_loss", + doc={"value": DOUBLE_PRECISION_LOSS}, + expression={"$log10": ["$value"]}, + expected=pytest.approx(15.954589770191003), + msg="$log10 should return the same value as the maximum safe integer for the next " + "integer double, which is not representable", + ), + ExpressionTestCase( + "very_small_positive", + doc={"value": 1e-300}, + expression={"$log10": ["$value"]}, + expected=-300.0, + msg="$log10 should return negative three hundred for a very small positive double", + ), +] + +# Property [Decimal128 Boundaries]: $log10 of extreme decimal128 exponents returns a full-precision +# decimal128 result. +LOG10_DECIMAL_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "decimal_small_exponent", + doc={"value": DECIMAL128_SMALL_EXPONENT}, + expression={"$log10": ["$value"]}, + expected=Decimal128("-6143"), + msg="$log10 should return the exact negative exponent for a decimal128 with a small " + "exponent", + ), + ExpressionTestCase( + "decimal_min_positive", + doc={"value": DECIMAL128_MIN_POSITIVE}, + expression={"$log10": ["$value"]}, + expected=Decimal128("-6176"), + msg="$log10 should return the exact negative exponent for the smallest positive decimal128", + ), + ExpressionTestCase( + "decimal_max", + doc={"value": DECIMAL128_MAX}, + expression={"$log10": ["$value"]}, + expected=Decimal128("6145"), + msg="$log10 should return the exponent for the maximum decimal128", + ), + ExpressionTestCase( + "decimal_trailing_zero", + doc={"value": DECIMAL128_TRAILING_ZERO}, + expression={"$log10": ["$value"]}, + expected=DECIMAL128_ZERO, + msg="$log10 should return zero for a decimal128 one with a trailing zero", + ), +] + +LOG10_BOUNDARY_ALL_TESTS = ( + LOG10_INTEGER_BOUNDARY_TESTS + LOG10_DOUBLE_BOUNDARY_TESTS + LOG10_DECIMAL_BOUNDARY_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(LOG10_BOUNDARY_ALL_TESTS)) +def test_log10_boundaries(collection, test_case: ExpressionTestCase): + """Test $log10 representable-range boundary cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/log10/test_log10_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/log10/test_log10_errors.py new file mode 100644 index 000000000..d31d80776 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/log10/test_log10_errors.py @@ -0,0 +1,254 @@ +"""Tests for $log10 domain, type, and arity errors.""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + EXPRESSION_TYPE_MISMATCH_ERROR, + LOG10_NON_POSITIVE_INPUT_ERROR, + NON_NUMERIC_TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_MAX_NEGATIVE, + DECIMAL128_MIN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ZERO, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_ZERO, + FLOAT_NEGATIVE_INFINITY, + INT32_MIN, + INT64_MIN, +) + +# Property [Domain]: $log10 rejects non-positive inputs, including zero, negative zero, negative +# values, and negative infinity. +LOG10_DOMAIN_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "zero_int32", + doc={"value": 0}, + expression={"$log10": ["$value"]}, + error_code=LOG10_NON_POSITIVE_INPUT_ERROR, + msg="$log10 should reject int32 zero", + ), + ExpressionTestCase( + "zero_double", + doc={"value": DOUBLE_ZERO}, + expression={"$log10": ["$value"]}, + error_code=LOG10_NON_POSITIVE_INPUT_ERROR, + msg="$log10 should reject double zero", + ), + ExpressionTestCase( + "zero_decimal", + doc={"value": DECIMAL128_ZERO}, + expression={"$log10": ["$value"]}, + error_code=LOG10_NON_POSITIVE_INPUT_ERROR, + msg="$log10 should reject decimal128 zero", + ), + ExpressionTestCase( + "negative_zero_double", + doc={"value": DOUBLE_NEGATIVE_ZERO}, + expression={"$log10": ["$value"]}, + error_code=LOG10_NON_POSITIVE_INPUT_ERROR, + msg="$log10 should reject negative zero double", + ), + ExpressionTestCase( + "negative_zero_decimal", + doc={"value": DECIMAL128_NEGATIVE_ZERO}, + expression={"$log10": ["$value"]}, + error_code=LOG10_NON_POSITIVE_INPUT_ERROR, + msg="$log10 should reject negative zero decimal128", + ), + ExpressionTestCase( + "negative_int32", + doc={"value": -1}, + expression={"$log10": ["$value"]}, + error_code=LOG10_NON_POSITIVE_INPUT_ERROR, + msg="$log10 should reject a negative integer", + ), + ExpressionTestCase( + "negative_ten", + doc={"value": -10}, + expression={"$log10": ["$value"]}, + error_code=LOG10_NON_POSITIVE_INPUT_ERROR, + msg="$log10 should reject negative ten", + ), + ExpressionTestCase( + "negative_double", + doc={"value": -0.5}, + expression={"$log10": ["$value"]}, + error_code=LOG10_NON_POSITIVE_INPUT_ERROR, + msg="$log10 should reject a negative double", + ), + ExpressionTestCase( + "negative_decimal", + doc={"value": Decimal128("-1")}, + expression={"$log10": ["$value"]}, + error_code=LOG10_NON_POSITIVE_INPUT_ERROR, + msg="$log10 should reject a negative decimal128", + ), + ExpressionTestCase( + "int32_min", + doc={"value": INT32_MIN}, + expression={"$log10": ["$value"]}, + error_code=LOG10_NON_POSITIVE_INPUT_ERROR, + msg="$log10 should reject INT32_MIN", + ), + ExpressionTestCase( + "int64_min", + doc={"value": INT64_MIN}, + expression={"$log10": ["$value"]}, + error_code=LOG10_NON_POSITIVE_INPUT_ERROR, + msg="$log10 should reject INT64_MIN", + ), + ExpressionTestCase( + "decimal_min", + doc={"value": DECIMAL128_MIN}, + expression={"$log10": ["$value"]}, + error_code=LOG10_NON_POSITIVE_INPUT_ERROR, + msg="$log10 should reject the minimum decimal128", + ), + ExpressionTestCase( + "decimal_max_negative", + doc={"value": DECIMAL128_MAX_NEGATIVE}, + expression={"$log10": ["$value"]}, + error_code=LOG10_NON_POSITIVE_INPUT_ERROR, + msg="$log10 should reject the smallest-magnitude negative decimal128", + ), + ExpressionTestCase( + "float_negative_infinity", + doc={"value": FLOAT_NEGATIVE_INFINITY}, + expression={"$log10": ["$value"]}, + error_code=LOG10_NON_POSITIVE_INPUT_ERROR, + msg="$log10 should reject float negative infinity", + ), + ExpressionTestCase( + "decimal128_negative_infinity", + doc={"value": DECIMAL128_NEGATIVE_INFINITY}, + expression={"$log10": ["$value"]}, + error_code=LOG10_NON_POSITIVE_INPUT_ERROR, + msg="$log10 should reject decimal128 negative infinity", + ), +] + +# Property [Type Strictness]: $log10 rejects non-numeric input types. +LOG10_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + *[ + ExpressionTestCase( + f"type_{tid}", + doc={"value": val}, + expression={"$log10": ["$value"]}, + error_code=NON_NUMERIC_TYPE_MISMATCH_ERROR, + msg=f"$log10 should reject a {tid} input", + ) + for tid, val in [ + ("string", "abc"), + ("bool", True), + ("array", [1, 2]), + ("object", {"a": 1}), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("regex", Regex("abc")), + ("binary", Binary(b"data")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("timestamp", Timestamp(1, 1)), + ("code", Code("function(){}")), + ] + ], + ExpressionTestCase( + "empty_array", + doc={"value": []}, + expression={"$log10": ["$value"]}, + error_code=NON_NUMERIC_TYPE_MISMATCH_ERROR, + msg="$log10 should reject an empty array input", + ), + ExpressionTestCase( + "empty_object", + doc={"value": {}}, + expression={"$log10": ["$value"]}, + error_code=NON_NUMERIC_TYPE_MISMATCH_ERROR, + msg="$log10 should reject an empty object input", + ), +] + +# Property [Non-Numeric Expression and Path Inputs]: array and object expression inputs, and field +# paths that resolve to an array, are delivered to $log10 as values and rejected as non-numeric. +LOG10_EXPRESSION_INPUT_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "array_expression_input", + doc={"value": 10}, + expression={"$log10": [["$value"]]}, + error_code=NON_NUMERIC_TYPE_MISMATCH_ERROR, + msg="$log10 should reject an array expression input without flattening it into arguments", + ), + ExpressionTestCase( + "object_expression_input", + doc={"value": 10}, + expression={"$log10": {"z": "$value"}}, + error_code=NON_NUMERIC_TYPE_MISMATCH_ERROR, + msg="$log10 should reject an object expression input as a value rather than evaluating it", + ), + ExpressionTestCase( + "array_of_objects_path", + doc={"a": [{"b": 10}, {"b": 100}]}, + expression={"$log10": "$a.b"}, + error_code=NON_NUMERIC_TYPE_MISMATCH_ERROR, + msg="$log10 should reject a field path that resolves to an array from an array of objects", + ), + ExpressionTestCase( + "array_index_path", + doc={"a": [{"b": 10}, {"b": 100}]}, + expression={"$log10": "$a.0.b"}, + error_code=NON_NUMERIC_TYPE_MISMATCH_ERROR, + msg="$log10 should reject an array-index field path that resolves to an array in an " + "aggregation expression", + ), +] + +# Property [Arity]: $log10 requires exactly one argument. +LOG10_ARITY_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "arity_zero", + doc={}, + expression={"$log10": []}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="$log10 should reject zero arguments", + ), + ExpressionTestCase( + "arity_two", + doc={}, + expression={"$log10": [1, 2]}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="$log10 should reject two arguments", + ), +] + +LOG10_ERROR_ALL_TESTS = ( + LOG10_DOMAIN_ERROR_TESTS + + LOG10_TYPE_ERROR_TESTS + + LOG10_EXPRESSION_INPUT_ERROR_TESTS + + LOG10_ARITY_ERROR_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(LOG10_ERROR_ALL_TESTS)) +def test_log10_errors(collection, test_case: ExpressionTestCase): + """Test $log10 domain, type, and arity error cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/log10/test_log10_input_forms.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/log10/test_log10_input_forms.py new file mode 100644 index 000000000..62da722c4 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/log10/test_log10_input_forms.py @@ -0,0 +1,70 @@ +"""Tests for $log10 argument forms, literal input, and nested expression input.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import DOUBLE_ZERO + +# Property [Argument Form]: $log10 accepts its single argument bare or wrapped in a one-element +# array. +LOG10_ARGUMENT_FORM_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "form_array", + doc={"value": 1}, + expression={"$log10": ["$value"]}, + expected=DOUBLE_ZERO, + msg="$log10 should accept its argument wrapped in a one-element array", + ), + ExpressionTestCase( + "form_bare", + doc={"value": 1}, + expression={"$log10": "$value"}, + expected=DOUBLE_ZERO, + msg="$log10 should accept its argument without an array wrapper", + ), +] + +# Property [Literal Input]: $log10 evaluates an inline literal argument, not only document fields. +LOG10_LITERAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "literal_input", + doc={}, + expression={"$log10": [10]}, + expected=1.0, + msg="$log10 should return one for an inline literal ten", + ), +] + +# Property [Expression Input]: $log10 evaluates a nested expression argument before taking the log. +LOG10_EXPRESSION_INPUT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_multiply", + doc={}, + expression={"$log10": {"$multiply": [10, 10]}}, + expected=2.0, + msg="$log10 should evaluate a nested $multiply expression argument", + ), +] + +LOG10_INPUT_FORM_TESTS = ( + LOG10_ARGUMENT_FORM_TESTS + LOG10_LITERAL_TESTS + LOG10_EXPRESSION_INPUT_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(LOG10_INPUT_FORM_TESTS)) +def test_log10_input_forms(collection, test_case: ExpressionTestCase): + """Test $log10 argument form, literal, and nested expression input cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/log10/test_log10_magnitude.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/log10/test_log10_magnitude.py new file mode 100644 index 000000000..d1a25bbb9 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/log10/test_log10_magnitude.py @@ -0,0 +1,332 @@ +"""Tests for $log10 core base-ten logarithm values across sign and numeric type.""" + +import math + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import DECIMAL128_ZERO, DOUBLE_ZERO + +# Property [Identity]: $log10 of one is zero for every numeric type. +LOG10_ONE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "one_int32", + doc={"value": 1}, + expression={"$log10": ["$value"]}, + expected=DOUBLE_ZERO, + msg="$log10 should return zero for int32 one", + ), + ExpressionTestCase( + "one_int64", + doc={"value": Int64(1)}, + expression={"$log10": ["$value"]}, + expected=DOUBLE_ZERO, + msg="$log10 should return zero for int64 one", + ), + ExpressionTestCase( + "one_double", + doc={"value": 1.0}, + expression={"$log10": ["$value"]}, + expected=DOUBLE_ZERO, + msg="$log10 should return zero for double one", + ), + ExpressionTestCase( + "one_decimal", + doc={"value": Decimal128("1")}, + expression={"$log10": ["$value"]}, + expected=DECIMAL128_ZERO, + msg="$log10 should return zero for decimal128 one", + ), +] + +# Property [Positive Powers of Ten]: $log10 of a positive power of ten returns its exact integer +# exponent as a double. +LOG10_POSITIVE_POWER_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "ten_int32", + doc={"value": 10}, + expression={"$log10": ["$value"]}, + expected=1.0, + msg="$log10 should return one for int32 ten", + ), + ExpressionTestCase( + "ten_int64", + doc={"value": Int64(10)}, + expression={"$log10": ["$value"]}, + expected=1.0, + msg="$log10 should return one for int64 ten", + ), + ExpressionTestCase( + "ten_double", + doc={"value": 10.0}, + expression={"$log10": ["$value"]}, + expected=1.0, + msg="$log10 should return one for double ten", + ), + ExpressionTestCase( + "hundred", + doc={"value": 100}, + expression={"$log10": ["$value"]}, + expected=2.0, + msg="$log10 should return two for one hundred", + ), + ExpressionTestCase( + "thousand", + doc={"value": 1000}, + expression={"$log10": ["$value"]}, + expected=3.0, + msg="$log10 should return three for one thousand", + ), + ExpressionTestCase( + "ten_thousand", + doc={"value": 10_000}, + expression={"$log10": ["$value"]}, + expected=4.0, + msg="$log10 should return four for ten thousand", + ), + ExpressionTestCase( + "million", + doc={"value": 1_000_000}, + expression={"$log10": ["$value"]}, + expected=6.0, + msg="$log10 should return six for one million", + ), + ExpressionTestCase( + "billion", + doc={"value": Int64(1_000_000_000)}, + expression={"$log10": ["$value"]}, + expected=9.0, + msg="$log10 should return nine for one billion int64", + ), + ExpressionTestCase( + "ten_billion", + doc={"value": Int64(10_000_000_000)}, + expression={"$log10": ["$value"]}, + expected=10.0, + msg="$log10 should return ten for ten billion int64", + ), + ExpressionTestCase( + "quadrillion", + doc={"value": 1e15}, + expression={"$log10": ["$value"]}, + expected=15.0, + msg="$log10 should return fifteen for one quadrillion", + ), + ExpressionTestCase( + "hundred_quintillion", + doc={"value": 1e20}, + expression={"$log10": ["$value"]}, + expected=20.0, + msg="$log10 should return twenty for one hundred quintillion", + ), +] + +# Property [Negative Powers of Ten]: $log10 of a fractional power of ten returns its exact negative +# integer exponent as a double. +LOG10_NEGATIVE_POWER_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tenth", + doc={"value": 0.1}, + expression={"$log10": ["$value"]}, + expected=-1.0, + msg="$log10 should return negative one for one tenth", + ), + ExpressionTestCase( + "hundredth", + doc={"value": 0.01}, + expression={"$log10": ["$value"]}, + expected=-2.0, + msg="$log10 should return negative two for one hundredth", + ), + ExpressionTestCase( + "thousandth", + doc={"value": 0.001}, + expression={"$log10": ["$value"]}, + expected=-3.0, + msg="$log10 should return negative three for one thousandth", + ), + ExpressionTestCase( + "ten_thousandth", + doc={"value": 0.0001}, + expression={"$log10": ["$value"]}, + expected=-4.0, + msg="$log10 should return negative four for one ten-thousandth", + ), + ExpressionTestCase( + "hundred_thousandth", + doc={"value": 1e-5}, + expression={"$log10": ["$value"]}, + expected=-5.0, + msg="$log10 should return negative five for one hundred-thousandth", + ), + ExpressionTestCase( + "billionth", + doc={"value": 1e-9}, + expression={"$log10": ["$value"]}, + expected=-9.0, + msg="$log10 should return negative nine for one billionth", + ), + ExpressionTestCase( + "ten_billionth", + doc={"value": 1e-10}, + expression={"$log10": ["$value"]}, + expected=-10.0, + msg="$log10 should return negative ten for one ten-billionth", + ), + ExpressionTestCase( + "quadrillionth", + doc={"value": 1e-15}, + expression={"$log10": ["$value"]}, + expected=-15.0, + msg="$log10 should return negative fifteen for one quadrillionth", + ), +] + +# Property [Non-Power Values]: $log10 of a value that is not a power of ten returns its +# base-ten logarithm. +LOG10_NON_POWER_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "two", + doc={"value": 2}, + expression={"$log10": ["$value"]}, + expected=pytest.approx(0.3010299956639812), + msg="$log10 should return the base-ten log of two", + ), + ExpressionTestCase( + "five", + doc={"value": 5}, + expression={"$log10": ["$value"]}, + expected=pytest.approx(0.6989700043360189), + msg="$log10 should return the base-ten log of five", + ), + ExpressionTestCase( + "seven", + doc={"value": 7}, + expression={"$log10": ["$value"]}, + expected=pytest.approx(0.8450980400142568), + msg="$log10 should return the base-ten log of seven", + ), + ExpressionTestCase( + "fifty", + doc={"value": 50}, + expression={"$log10": ["$value"]}, + expected=pytest.approx(1.6989700043360187), + msg="$log10 should return the base-ten log of fifty", + ), + ExpressionTestCase( + "five_hundred", + doc={"value": 500}, + expression={"$log10": ["$value"]}, + expected=pytest.approx(2.6989700043360187), + msg="$log10 should return the base-ten log of five hundred", + ), + ExpressionTestCase( + "five_billionth", + doc={"value": 0.000000005}, + expression={"$log10": ["$value"]}, + expected=pytest.approx(-8.301029995663981), + msg="$log10 should return the base-ten log of five billionths", + ), + ExpressionTestCase( + "half", + doc={"value": 0.5}, + expression={"$log10": ["$value"]}, + expected=pytest.approx(-0.3010299956639812), + msg="$log10 should return the base-ten log of one half", + ), + ExpressionTestCase( + "quarter", + doc={"value": 0.25}, + expression={"$log10": ["$value"]}, + expected=pytest.approx(-0.6020599913279624), + msg="$log10 should return the base-ten log of one quarter", + ), + ExpressionTestCase( + "one_and_half", + doc={"value": 1.5}, + expression={"$log10": ["$value"]}, + expected=pytest.approx(0.17609125905568124), + msg="$log10 should return the base-ten log of one and a half", + ), + ExpressionTestCase( + "two_and_half", + doc={"value": 2.5}, + expression={"$log10": ["$value"]}, + expected=pytest.approx(0.3979400086720376), + msg="$log10 should return the base-ten log of two and a half", + ), + ExpressionTestCase( + "pi", + doc={"value": math.pi}, + expression={"$log10": ["$value"]}, + expected=pytest.approx(0.4971498726941338), + msg="$log10 should return the base-ten log of pi", + ), + ExpressionTestCase( + "e", + doc={"value": math.e}, + expression={"$log10": ["$value"]}, + expected=pytest.approx(0.4342944819032518), + msg="$log10 should return the base-ten log of e", + ), + ExpressionTestCase( + "e_squared", + doc={"value": math.e**2}, + expression={"$log10": ["$value"]}, + expected=pytest.approx(0.8685889638065036), + msg="$log10 should return the base-ten log of e squared", + ), +] + +# Property [Decimal Precision]: $log10 of a decimal128 input returns a full-precision decimal128. +LOG10_DECIMAL_PRECISION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "decimal_ten", + doc={"value": Decimal128("10")}, + expression={"$log10": ["$value"]}, + expected=Decimal128("1"), + msg="$log10 should return one as a decimal128 for decimal128 ten", + ), + ExpressionTestCase( + "decimal_hundred", + doc={"value": Decimal128("100")}, + expression={"$log10": ["$value"]}, + expected=Decimal128("2"), + msg="$log10 should return two as a decimal128 for decimal128 one hundred", + ), + ExpressionTestCase( + "decimal_two", + doc={"value": Decimal128("2")}, + expression={"$log10": ["$value"]}, + expected=Decimal128("0.3010299956639811952137388947244930"), + msg="$log10 should return a full-precision decimal128 for decimal128 two", + ), +] + +LOG10_MAGNITUDE_ALL_TESTS = ( + LOG10_ONE_TESTS + + LOG10_POSITIVE_POWER_TESTS + + LOG10_NEGATIVE_POWER_TESTS + + LOG10_NON_POWER_TESTS + + LOG10_DECIMAL_PRECISION_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(LOG10_MAGNITUDE_ALL_TESTS)) +def test_log10_magnitude(collection, test_case: ExpressionTestCase): + """Test $log10 base-ten logarithm value cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/log10/test_log10_non_finite.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/log10/test_log10_non_finite.py new file mode 100644 index 000000000..c6c4760c3 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/log10/test_log10_non_finite.py @@ -0,0 +1,68 @@ +"""Tests for $log10 of positive infinity and NaN inputs.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + FLOAT_INFINITY, + FLOAT_NAN, +) + +# Property [Infinity]: $log10 of positive infinity is infinity of the same type. +LOG10_INFINITY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "float_infinity", + doc={"value": FLOAT_INFINITY}, + expression={"$log10": ["$value"]}, + expected=FLOAT_INFINITY, + msg="$log10 should return infinity for float infinity", + ), + ExpressionTestCase( + "decimal128_infinity", + doc={"value": DECIMAL128_INFINITY}, + expression={"$log10": ["$value"]}, + expected=DECIMAL128_INFINITY, + msg="$log10 should return decimal128 infinity for decimal128 infinity", + ), +] + +# Property [NaN]: $log10 of NaN returns a double NaN, including for a decimal128 NaN input. +LOG10_NAN_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "float_nan", + doc={"value": FLOAT_NAN}, + expression={"$log10": ["$value"]}, + expected=pytest.approx(FLOAT_NAN, nan_ok=True), + msg="$log10 should return NaN for float NaN", + ), + ExpressionTestCase( + "decimal128_nan", + doc={"value": DECIMAL128_NAN}, + expression={"$log10": ["$value"]}, + expected=pytest.approx(FLOAT_NAN, nan_ok=True), + msg="$log10 should return NaN for decimal128 NaN", + ), +] + +LOG10_NON_FINITE_TESTS = LOG10_INFINITY_TESTS + LOG10_NAN_TESTS + + +@pytest.mark.parametrize("test_case", pytest_params(LOG10_NON_FINITE_TESTS)) +def test_log10_non_finite(collection, test_case: ExpressionTestCase): + """Test $log10 positive infinity and NaN cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/log10/test_log10_null.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/log10/test_log10_null.py new file mode 100644 index 000000000..0f82b3181 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/log10/test_log10_null.py @@ -0,0 +1,43 @@ +"""Tests for $log10 null and missing field propagation.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import MISSING + +# Property [Null Propagation]: $log10 of null or a missing field returns null. +LOG10_NULL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_value", + doc={"value": None}, + expression={"$log10": ["$value"]}, + expected=None, + msg="$log10 should return null for null input", + ), + ExpressionTestCase( + "missing_field", + doc={}, + expression={"$log10": [MISSING]}, + expected=None, + msg="$log10 should return null for a missing field", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(LOG10_NULL_TESTS)) +def test_log10_null(collection, test_case: ExpressionTestCase): + """Test $log10 null and missing field propagation cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/log10/test_log10_return_type.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/log10/test_log10_return_type.py new file mode 100644 index 000000000..9295b793d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/log10/test_log10_return_type.py @@ -0,0 +1,58 @@ +"""Tests for $log10 return type across numeric input types.""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Return Type]: $log10 returns double for int32, int64, and double inputs, and decimal +# for decimal128 inputs. +LOG10_RETURN_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "return_type_int32", + doc={"value": 1}, + expression={"$type": {"$log10": "$value"}}, + expected="double", + msg="$log10 should return a double for an int32 input", + ), + ExpressionTestCase( + "return_type_int64", + doc={"value": Int64(1)}, + expression={"$type": {"$log10": "$value"}}, + expected="double", + msg="$log10 should return a double for an int64 input", + ), + ExpressionTestCase( + "return_type_double", + doc={"value": 1.0}, + expression={"$type": {"$log10": "$value"}}, + expected="double", + msg="$log10 should return a double for a double input", + ), + ExpressionTestCase( + "return_type_decimal", + doc={"value": Decimal128("1")}, + expression={"$type": {"$log10": "$value"}}, + expected="decimal", + msg="$log10 should return a decimal for a decimal128 input", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(LOG10_RETURN_TYPE_TESTS)) +def test_log10_return_type(collection, test_case: ExpressionTestCase): + """Test $log10 return type cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) From 9eeb3517e3e0b3ae7ee8f7b329eb0be8f1ca857e Mon Sep 17 00:00:00 2001 From: Daniel Frankcom Date: Wed, 15 Jul 2026 14:35:53 -0700 Subject: [PATCH 33/35] Add $floor arithmetic expression tests (#664) Signed-off-by: Daniel Frankcom Co-authored-by: Daniel Frankcom Co-authored-by: Victor Tsang Signed-off-by: RyanGarfinkel <113050972+RyanGarfinkel@users.noreply.github.com> --- .../arithmetic/floor/test_floor_boundaries.py | 241 ++++++++++++++++++ .../arithmetic/floor/test_floor_core.py | 152 +++++++++++ .../floor/test_floor_input_forms.py | 100 ++++++++ .../floor/test_floor_nan_infinity.py | 84 ++++++ .../floor/test_floor_null_missing.py | 41 +++ .../floor/test_floor_return_type.py | 63 +++++ .../floor/test_floor_type_errors.py | 75 ++++++ 7 files changed, 756 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/floor/test_floor_boundaries.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/floor/test_floor_core.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/floor/test_floor_input_forms.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/floor/test_floor_nan_infinity.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/floor/test_floor_null_missing.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/floor/test_floor_return_type.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/floor/test_floor_type_errors.py diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/floor/test_floor_boundaries.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/floor/test_floor_boundaries.py new file mode 100644 index 000000000..729c8e31b --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/floor/test_floor_boundaries.py @@ -0,0 +1,241 @@ +"""Tests for $floor at representable-range, subnormal, and decimal128 exponent boundaries.""" + +from __future__ import annotations + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_JUST_ABOVE_HALF, + DECIMAL128_JUST_BELOW_HALF, + DECIMAL128_LARGE_EXPONENT, + DECIMAL128_MANY_TRAILING_ZEROS, + DECIMAL128_MAX, + DECIMAL128_MIN, + DECIMAL128_NAN, + DECIMAL128_SMALL_EXPONENT, + DECIMAL128_TRAILING_ZERO, + DECIMAL128_ZERO, + DOUBLE_JUST_ABOVE_HALF, + DOUBLE_JUST_BELOW_HALF, + DOUBLE_MAX_SAFE_INTEGER, + DOUBLE_MIN_NEGATIVE_SUBNORMAL, + DOUBLE_MIN_SUBNORMAL, + DOUBLE_NEAR_MAX, + DOUBLE_NEAR_MIN, + DOUBLE_PRECISION_LOSS, + DOUBLE_ZERO, + INT32_MAX, + INT32_MIN, + INT32_OVERFLOW, + INT32_UNDERFLOW, + INT64_MAX, + INT64_MAX_MINUS_1, + INT64_MIN, + INT64_MIN_PLUS_1, +) + +# Property [Integer Boundaries]: floor preserves integer-type values at representable-range +# boundaries, and a value just past the int32 range stays a long. +FLOOR_INT_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "boundary_int32_max", + doc={"value": INT32_MAX}, + expression={"$floor": ["$value"]}, + expected=INT32_MAX, + msg="$floor should return INT32_MAX unchanged as an int", + ), + ExpressionTestCase( + "boundary_int32_min", + doc={"value": INT32_MIN}, + expression={"$floor": ["$value"]}, + expected=INT32_MIN, + msg="$floor should return INT32_MIN unchanged as an int", + ), + ExpressionTestCase( + "boundary_int32_overflow", + doc={"value": INT32_OVERFLOW}, + expression={"$floor": ["$value"]}, + expected=Int64(INT32_OVERFLOW), + msg="$floor should return a value just above the int32 range unchanged as a long", + ), + ExpressionTestCase( + "boundary_int32_underflow", + doc={"value": INT32_UNDERFLOW}, + expression={"$floor": ["$value"]}, + expected=Int64(INT32_UNDERFLOW), + msg="$floor should return a value just below the int32 range unchanged as a long", + ), + ExpressionTestCase( + "boundary_int64_max", + doc={"value": INT64_MAX}, + expression={"$floor": ["$value"]}, + expected=INT64_MAX, + msg="$floor should return INT64_MAX unchanged as a long", + ), + ExpressionTestCase( + "boundary_int64_max_minus_1", + doc={"value": INT64_MAX_MINUS_1}, + expression={"$floor": ["$value"]}, + expected=INT64_MAX_MINUS_1, + msg="$floor should return INT64_MAX-1 unchanged as a long", + ), + ExpressionTestCase( + "boundary_int64_min", + doc={"value": INT64_MIN}, + expression={"$floor": ["$value"]}, + expected=INT64_MIN, + msg="$floor should return INT64_MIN unchanged as a long", + ), + ExpressionTestCase( + "boundary_int64_min_plus_1", + doc={"value": INT64_MIN_PLUS_1}, + expression={"$floor": ["$value"]}, + expected=INT64_MIN_PLUS_1, + msg="$floor should return INT64_MIN+1 unchanged as a long", + ), +] + +# Property [Double Boundaries]: floor handles subnormal and extreme double magnitudes, +# including values on either side of the 0.5 rounding boundary and at the integer-representability +# limit, where doubles have no fractional part and floor is an identity. +FLOOR_DOUBLE_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "boundary_double_min_subnormal", + doc={"value": DOUBLE_MIN_SUBNORMAL}, + expression={"$floor": ["$value"]}, + expected=DOUBLE_ZERO, + msg="$floor should floor the smallest positive subnormal double to DOUBLE_ZERO", + ), + ExpressionTestCase( + "boundary_double_min_negative_subnormal", + doc={"value": DOUBLE_MIN_NEGATIVE_SUBNORMAL}, + expression={"$floor": ["$value"]}, + expected=-1.0, + msg="$floor should floor the smallest negative subnormal double to -1.0", + ), + ExpressionTestCase( + "boundary_double_near_min", + doc={"value": DOUBLE_NEAR_MIN}, + expression={"$floor": ["$value"]}, + expected=DOUBLE_ZERO, + msg="$floor should floor a near-min positive double to DOUBLE_ZERO", + ), + ExpressionTestCase( + "boundary_double_near_max", + doc={"value": DOUBLE_NEAR_MAX}, + expression={"$floor": ["$value"]}, + expected=DOUBLE_NEAR_MAX, + msg="$floor should return a near-max whole-magnitude double unchanged", + ), + ExpressionTestCase( + "boundary_double_max_safe_integer", + doc={"value": float(DOUBLE_MAX_SAFE_INTEGER)}, + expression={"$floor": ["$value"]}, + expected=float(DOUBLE_MAX_SAFE_INTEGER), + msg="$floor should return the max safe integer double unchanged, having no fractional part", + ), + ExpressionTestCase( + "boundary_double_precision_loss", + doc={"value": float(DOUBLE_PRECISION_LOSS)}, + expression={"$floor": ["$value"]}, + expected=float(DOUBLE_PRECISION_LOSS), + msg="$floor should return a precision-loss double unchanged, having no fractional part", + ), + ExpressionTestCase( + "boundary_double_just_below_half", + doc={"value": DOUBLE_JUST_BELOW_HALF}, + expression={"$floor": ["$value"]}, + expected=DOUBLE_ZERO, + msg="$floor should floor a double just below 0.5 to DOUBLE_ZERO", + ), + ExpressionTestCase( + "boundary_double_just_above_half", + doc={"value": DOUBLE_JUST_ABOVE_HALF}, + expression={"$floor": ["$value"]}, + expected=DOUBLE_ZERO, + msg="$floor should floor a double just above 0.5 to DOUBLE_ZERO", + ), +] + +# Property [Decimal Boundaries]: floor handles decimal128 exponent extremes, trailing-zero +# normalization, and values around the 0.5 boundary, returning NaN when the magnitude overflows. +FLOOR_DECIMAL_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "boundary_decimal_max", + doc={"value": DECIMAL128_MAX}, + expression={"$floor": ["$value"]}, + expected=DECIMAL128_NAN, + msg="$floor should return NaN for the maximum decimal128 magnitude", + ), + ExpressionTestCase( + "boundary_decimal_min", + doc={"value": DECIMAL128_MIN}, + expression={"$floor": ["$value"]}, + expected=DECIMAL128_NAN, + msg="$floor should return NaN for the minimum decimal128 magnitude", + ), + ExpressionTestCase( + "boundary_decimal_large_exponent", + doc={"value": DECIMAL128_LARGE_EXPONENT}, + expression={"$floor": ["$value"]}, + expected=DECIMAL128_NAN, + msg="$floor should return NaN for a decimal128 with a large positive exponent", + ), + ExpressionTestCase( + "boundary_decimal_small_exponent", + doc={"value": DECIMAL128_SMALL_EXPONENT}, + expression={"$floor": ["$value"]}, + expected=DECIMAL128_ZERO, + msg="$floor should floor a decimal128 with a large negative exponent to 0", + ), + ExpressionTestCase( + "boundary_decimal_trailing_zero", + doc={"value": DECIMAL128_TRAILING_ZERO}, + expression={"$floor": ["$value"]}, + expected=Decimal128("1"), + msg="$floor should normalize a whole decimal128 with a trailing zero to 1", + ), + ExpressionTestCase( + "boundary_decimal_many_trailing_zeros", + doc={"value": DECIMAL128_MANY_TRAILING_ZEROS}, + expression={"$floor": ["$value"]}, + expected=Decimal128("1"), + msg="$floor should normalize a whole decimal128 with many trailing zeros to 1", + ), + ExpressionTestCase( + "boundary_decimal_just_below_half", + doc={"value": DECIMAL128_JUST_BELOW_HALF}, + expression={"$floor": ["$value"]}, + expected=DECIMAL128_ZERO, + msg="$floor should floor a decimal128 just below 0.5 to 0", + ), + ExpressionTestCase( + "boundary_decimal_just_above_half", + doc={"value": DECIMAL128_JUST_ABOVE_HALF}, + expression={"$floor": ["$value"]}, + expected=DECIMAL128_ZERO, + msg="$floor should floor a decimal128 just above 0.5 to 0", + ), +] + +FLOOR_BOUNDARY_TESTS = ( + FLOOR_INT_BOUNDARY_TESTS + FLOOR_DOUBLE_BOUNDARY_TESTS + FLOOR_DECIMAL_BOUNDARY_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(FLOOR_BOUNDARY_TESTS)) +def test_floor_boundaries(collection, test): + """Test $floor at numeric boundaries.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/floor/test_floor_core.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/floor/test_floor_core.py new file mode 100644 index 000000000..9a280cdd8 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/floor/test_floor_core.py @@ -0,0 +1,152 @@ +"""Tests for $floor core flooring behavior across integer, double, and decimal128 inputs.""" + +from __future__ import annotations + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_NEGATIVE_ONE_AND_HALF, + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ONE_AND_HALF, + DECIMAL128_ZERO, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_ZERO, + INT64_ZERO, +) + +# Property [Integer Identity]: floor returns integer inputs unchanged, preserving int/long type. +FLOOR_INTEGER_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "identity_positive_int32", + doc={"value": 1}, + expression={"$floor": ["$value"]}, + expected=1, + msg="$floor should return a positive int32 unchanged", + ), + ExpressionTestCase( + "identity_negative_int32", + doc={"value": -1}, + expression={"$floor": ["$value"]}, + expected=-1, + msg="$floor should return a negative int32 unchanged", + ), + ExpressionTestCase( + "identity_zero_int32", + doc={"value": 0}, + expression={"$floor": ["$value"]}, + expected=0, + msg="$floor should return int32 zero unchanged", + ), + ExpressionTestCase( + "identity_positive_int64", + doc={"value": Int64(1)}, + expression={"$floor": ["$value"]}, + expected=Int64(1), + msg="$floor should return a positive int64 unchanged", + ), + ExpressionTestCase( + "identity_negative_int64", + doc={"value": Int64(-1)}, + expression={"$floor": ["$value"]}, + expected=Int64(-1), + msg="$floor should return a negative int64 unchanged", + ), + ExpressionTestCase( + "identity_zero_int64", + doc={"value": INT64_ZERO}, + expression={"$floor": ["$value"]}, + expected=INT64_ZERO, + msg="$floor should return int64 zero unchanged", + ), +] + +# Property [Double Flooring]: floor rounds a double down to the nearest integer, returning a double. +FLOOR_DOUBLE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "double_positive", + doc={"value": 1.5}, + expression={"$floor": ["$value"]}, + expected=1.0, + msg="$floor should round a positive double down", + ), + ExpressionTestCase( + "double_negative", + doc={"value": -1.5}, + expression={"$floor": ["$value"]}, + expected=-2.0, + msg="$floor should round a negative double down, away from zero", + ), + ExpressionTestCase( + "double_zero", + doc={"value": DOUBLE_ZERO}, + expression={"$floor": ["$value"]}, + expected=DOUBLE_ZERO, + msg="$floor should return double zero unchanged", + ), + ExpressionTestCase( + "double_negative_zero", + doc={"value": DOUBLE_NEGATIVE_ZERO}, + expression={"$floor": ["$value"]}, + expected=DOUBLE_NEGATIVE_ZERO, + msg="$floor should preserve negative zero for a double", + ), +] + +# Property [Decimal Flooring]: floor rounds a decimal128 down, returning decimal128. +FLOOR_DECIMAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "decimal_positive_fraction", + doc={"value": DECIMAL128_ONE_AND_HALF}, + expression={"$floor": ["$value"]}, + expected=Decimal128("1"), + msg="$floor should round a positive decimal128 down", + ), + ExpressionTestCase( + "decimal_negative_fraction", + doc={"value": DECIMAL128_NEGATIVE_ONE_AND_HALF}, + expression={"$floor": ["$value"]}, + expected=Decimal128("-2"), + msg="$floor should round a negative decimal128 down, away from zero", + ), + ExpressionTestCase( + "decimal_whole", + doc={"value": Decimal128("1")}, + expression={"$floor": ["$value"]}, + expected=Decimal128("1"), + msg="$floor should return a whole decimal128 unchanged", + ), + ExpressionTestCase( + "decimal_zero", + doc={"value": DECIMAL128_ZERO}, + expression={"$floor": ["$value"]}, + expected=DECIMAL128_ZERO, + msg="$floor should return decimal128 zero unchanged", + ), + ExpressionTestCase( + "decimal_negative_zero", + doc={"value": DECIMAL128_NEGATIVE_ZERO}, + expression={"$floor": ["$value"]}, + expected=DECIMAL128_NEGATIVE_ZERO, + msg="$floor should preserve negative zero for a decimal128", + ), +] + +FLOOR_CORE_TESTS = FLOOR_INTEGER_TESTS + FLOOR_DOUBLE_TESTS + FLOOR_DECIMAL_TESTS + + +@pytest.mark.parametrize("test", pytest_params(FLOOR_CORE_TESTS)) +def test_floor_core(collection, test): + """Test $floor core flooring behavior.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/floor/test_floor_input_forms.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/floor/test_floor_input_forms.py new file mode 100644 index 000000000..f9d8f3d5c --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/floor/test_floor_input_forms.py @@ -0,0 +1,100 @@ +"""Tests for $floor argument forms, literals, field paths, and nested expression inputs.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import NON_NUMERIC_TYPE_MISMATCH_ERROR +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Argument Form]: $floor accepts its single argument bare or wrapped in a one-element +# array. +FLOOR_ARGUMENT_FORM_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "form_array", + doc={"value": -1.5}, + expression={"$floor": ["$value"]}, + expected=-2.0, + msg="$floor should accept its argument wrapped in a one-element array", + ), + ExpressionTestCase( + "form_bare", + doc={"value": -1.5}, + expression={"$floor": "$value"}, + expected=-2.0, + msg="$floor should accept its argument without an array wrapper", + ), +] + +# Property [Literal Input]: $floor evaluates an inline literal argument, not only document fields. +FLOOR_LITERAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "literal_input", + doc={}, + expression={"$floor": [1.5]}, + expected=1.0, + msg="$floor should round down an inline literal argument", + ), +] + +# Property [Expression Input]: $floor evaluates a nested expression argument before flooring. +FLOOR_EXPRESSION_INPUT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_expression", + doc={}, + expression={"$floor": {"$floor": -4.1}}, + expected=-5.0, + msg="$floor should evaluate a nested $floor expression argument", + ), +] + +# Property [Field Path Input]: $floor resolves a field path argument. A dotted path into a nested +# object yields the referenced value; a path over an array (an array-of-objects path, or a numeric +# component applied over an array) resolves to an array, which $floor rejects as a non-numeric type. +FLOOR_FIELD_PATH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_field_path", + doc={"a": {"b": -1.5}}, + expression={"$floor": "$a.b"}, + expected=-2.0, + msg="$floor should resolve a dotted field path into a nested object", + ), + ExpressionTestCase( + "composite_array_field_path", + doc={"a": [{"b": -1.5}, {"b": -2.5}]}, + expression={"$floor": "$a.b"}, + error_code=NON_NUMERIC_TYPE_MISMATCH_ERROR, + msg="$floor should reject a field path that resolves to an array from an array of objects", + ), + ExpressionTestCase( + "array_index_field_path", + doc={"a": [-1.5, -2.5]}, + expression={"$floor": "$a.0"}, + error_code=NON_NUMERIC_TYPE_MISMATCH_ERROR, + msg="$floor should reject a numeric path component over an array, resolving non-numeric", + ), +] + +FLOOR_INPUT_FORM_TESTS = ( + FLOOR_ARGUMENT_FORM_TESTS + + FLOOR_LITERAL_TESTS + + FLOOR_EXPRESSION_INPUT_TESTS + + FLOOR_FIELD_PATH_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(FLOOR_INPUT_FORM_TESTS)) +def test_floor_input_forms(collection, test_case: ExpressionTestCase): + """Test $floor argument form, literal, field path, and nested expression input cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/floor/test_floor_nan_infinity.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/floor/test_floor_nan_infinity.py new file mode 100644 index 000000000..c90716782 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/floor/test_floor_nan_infinity.py @@ -0,0 +1,84 @@ +"""Tests for $floor handling of infinities and NaN across double and decimal128 inputs.""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, +) + +# Property [Infinity]: floor returns floating-point infinities unchanged, while decimal128 +# infinities floor to NaN. +FLOOR_INFINITY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "infinity_float_positive", + doc={"value": FLOAT_INFINITY}, + expression={"$floor": ["$value"]}, + expected=FLOAT_INFINITY, + msg="$floor should return positive infinity unchanged", + ), + ExpressionTestCase( + "infinity_float_negative", + doc={"value": FLOAT_NEGATIVE_INFINITY}, + expression={"$floor": ["$value"]}, + expected=FLOAT_NEGATIVE_INFINITY, + msg="$floor should return negative infinity unchanged", + ), + ExpressionTestCase( + "infinity_decimal_positive", + doc={"value": DECIMAL128_INFINITY}, + expression={"$floor": ["$value"]}, + expected=DECIMAL128_NAN, + msg="$floor should return NaN for decimal128 positive infinity", + ), + ExpressionTestCase( + "infinity_decimal_negative", + doc={"value": DECIMAL128_NEGATIVE_INFINITY}, + expression={"$floor": ["$value"]}, + expected=DECIMAL128_NAN, + msg="$floor should return NaN for decimal128 negative infinity", + ), +] + +# Property [NaN]: floor of NaN is NaN, preserving the input's floating-point family. +FLOOR_NAN_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nan_float", + doc={"value": FLOAT_NAN}, + expression={"$floor": ["$value"]}, + expected=pytest.approx(FLOAT_NAN, nan_ok=True), + msg="$floor should return NaN for a double NaN", + ), + ExpressionTestCase( + "nan_decimal", + doc={"value": DECIMAL128_NAN}, + expression={"$floor": ["$value"]}, + expected=DECIMAL128_NAN, + msg="$floor should return NaN for a decimal128 NaN", + ), +] + +FLOOR_NAN_INFINITY_TESTS = FLOOR_INFINITY_TESTS + FLOOR_NAN_TESTS + + +@pytest.mark.parametrize("test", pytest_params(FLOOR_NAN_INFINITY_TESTS)) +def test_floor_nan_infinity(collection, test): + """Test $floor handling of infinities and NaN.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/floor/test_floor_null_missing.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/floor/test_floor_null_missing.py new file mode 100644 index 000000000..213b8fdfd --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/floor/test_floor_null_missing.py @@ -0,0 +1,41 @@ +"""Tests for $floor returning null on null and missing-field inputs.""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Null and Missing]: floor returns null when the input is null or a missing field. +FLOOR_NULL_MISSING_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_value", + doc={"value": None}, + expression={"$floor": ["$value"]}, + expected=None, + msg="$floor should return null for a null input", + ), + ExpressionTestCase( + "missing_field", + doc={}, + expression={"$floor": ["$value"]}, + expected=None, + msg="$floor should return null for a missing field input", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(FLOOR_NULL_MISSING_TESTS)) +def test_floor_null_missing(collection, test): + """Test $floor null and missing propagation.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/floor/test_floor_return_type.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/floor/test_floor_return_type.py new file mode 100644 index 000000000..cf0ac8f2d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/floor/test_floor_return_type.py @@ -0,0 +1,63 @@ +"""Tests for $floor preserving the input numeric type in its result.""" + +from __future__ import annotations + +import pytest +from bson import Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import DECIMAL128_ONE_AND_HALF + +# Property [Return Type]: floor preserves the input's numeric type, and a whole-number double +# stays a double rather than being coerced to an integer. +FLOOR_RETURN_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "return_type_int32", + doc={"value": 5}, + expression={"$type": {"$floor": ["$value"]}}, + expected="int", + msg="$floor should return an int for an int32 input", + ), + ExpressionTestCase( + "return_type_int64", + doc={"value": Int64(5)}, + expression={"$type": {"$floor": ["$value"]}}, + expected="long", + msg="$floor should return a long for an int64 input", + ), + ExpressionTestCase( + "return_type_double_fraction", + doc={"value": 1.5}, + expression={"$type": {"$floor": ["$value"]}}, + expected="double", + msg="$floor should return a double for a fractional double input", + ), + ExpressionTestCase( + "return_type_double_whole", + doc={"value": 3.0}, + expression={"$type": {"$floor": ["$value"]}}, + expected="double", + msg="$floor should keep a whole-number double as a double, not coerce it to an int", + ), + ExpressionTestCase( + "return_type_decimal", + doc={"value": DECIMAL128_ONE_AND_HALF}, + expression={"$type": {"$floor": ["$value"]}}, + expected="decimal", + msg="$floor should return a decimal for a decimal128 input", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(FLOOR_RETURN_TYPE_TESTS)) +def test_floor_return_type(collection, test): + """Test $floor preserves the input numeric type.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result(result, expected=test.expected, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/floor/test_floor_type_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/floor/test_floor_type_errors.py new file mode 100644 index 000000000..d9e600ec2 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/floor/test_floor_type_errors.py @@ -0,0 +1,75 @@ +"""Tests for $floor rejection of non-numeric input types and wrong argument counts.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + EXPRESSION_TYPE_MISMATCH_ERROR, + NON_NUMERIC_TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Type Strictness]: a non-numeric input produces NON_NUMERIC_TYPE_MISMATCH_ERROR. +FLOOR_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"type_error_{tid}", + doc={"value": val}, + expression={"$floor": ["$value"]}, + error_code=NON_NUMERIC_TYPE_MISMATCH_ERROR, + msg=f"$floor should reject a {tid} input as non-numeric", + ) + for tid, val in [ + ("string", "string"), + ("bool", True), + ("array", [1, 2]), + ("object", {"a": 1}), + ("date", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("binary", Binary(b"data")), + ("regex", Regex("abc")), + ("timestamp", Timestamp(1, 1)), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("javascript", Code("function(){}")), + ] +] + +# Property [Arity]: floor requires exactly one argument. +FLOOR_ARITY_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "arity_no_args", + doc={}, + expression={"$floor": []}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="$floor should reject a call with no arguments", + ), + ExpressionTestCase( + "arity_two_args", + doc={}, + expression={"$floor": [1, 2]}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="$floor should reject a call with two arguments", + ), +] + +FLOOR_ERROR_TESTS = FLOOR_TYPE_ERROR_TESTS + FLOOR_ARITY_ERROR_TESTS + + +@pytest.mark.parametrize("test", pytest_params(FLOOR_ERROR_TESTS)) +def test_floor_type_errors(collection, test): + """Test $floor type and arity rejection.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) From 494a53be61a8b520b8617d82a64a521a51f5212c Mon Sep 17 00:00:00 2001 From: Victor Tsang Date: Wed, 15 Jul 2026 14:58:01 -0700 Subject: [PATCH 34/35] Add $mod, $multiply, $pow tests (#687) Signed-off-by: Victor [C] Tsang Co-authored-by: Victor [C] Tsang Co-authored-by: Mitchell Elholm Signed-off-by: RyanGarfinkel <113050972+RyanGarfinkel@users.noreply.github.com> --- .../expressions/arithmetic/__init__.py | 0 .../expressions/arithmetic/mod/__init__.py | 0 .../arithmetic/mod/test_mod_basic_sign.py | 88 ++++ .../mod/test_mod_bson_type_validation.py | 87 ++++ .../mod/test_mod_double_decimal_boundaries.py | 122 ++++++ .../arithmetic/mod/test_mod_errors.py | 174 ++++++++ .../mod/test_mod_expression_types.py | 76 ++++ .../mod/test_mod_integer_boundaries.py | 148 +++++++ .../arithmetic/mod/test_mod_nan_infinity.py | 172 ++++++++ .../arithmetic/mod/test_mod_null_missing.py | 86 ++++ .../mod/test_mod_rounding_halves.py | 74 ++++ .../arithmetic/mod/test_mod_type_matrix.py | 110 +++++ .../arithmetic/multiply/__init__.py | 0 .../multiply/test_multiply_boundaries.py | 372 ++++++++++++++++ .../test_multiply_bson_type_validation.py | 83 ++++ .../multiply/test_multiply_errors.py | 105 +++++ .../test_multiply_expression_types.py | 62 +++ .../multiply/test_multiply_infinity_nan.py | 394 +++++++++++++++++ .../test_multiply_null_missing_sign_zero.py | 333 +++++++++++++++ .../multiply/test_multiply_rounding_halves.py | 380 ++++++++++++++++ .../multiply/test_multiply_type_matrix.py | 268 ++++++++++++ .../multiply/test_multiply_variadic_arity.py | 404 ++++++++++++++++++ .../expressions/arithmetic/pow/__init__.py | 0 .../pow/test_pow_boundaries_precision.py | 293 +++++++++++++ .../pow/test_pow_bson_type_validation.py | 84 ++++ .../pow/test_pow_core_arithmetic.py | 249 +++++++++++ .../arithmetic/pow/test_pow_errors.py | 215 ++++++++++ .../pow/test_pow_field_ref_wiring.py | 116 +++++ .../pow/test_pow_null_missing_infinity.py | 224 ++++++++++ .../core/operator/query/misc/__init__.py | 0 30 files changed, 4719 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/mod/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/mod/test_mod_basic_sign.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/mod/test_mod_bson_type_validation.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/mod/test_mod_double_decimal_boundaries.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/mod/test_mod_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/mod/test_mod_expression_types.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/mod/test_mod_integer_boundaries.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/mod/test_mod_nan_infinity.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/mod/test_mod_null_missing.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/mod/test_mod_rounding_halves.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/mod/test_mod_type_matrix.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/multiply/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/multiply/test_multiply_boundaries.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/multiply/test_multiply_bson_type_validation.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/multiply/test_multiply_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/multiply/test_multiply_expression_types.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/multiply/test_multiply_infinity_nan.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/multiply/test_multiply_null_missing_sign_zero.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/multiply/test_multiply_rounding_halves.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/multiply/test_multiply_type_matrix.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/multiply/test_multiply_variadic_arity.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/pow/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/pow/test_pow_boundaries_precision.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/pow/test_pow_bson_type_validation.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/pow/test_pow_core_arithmetic.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/pow/test_pow_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/pow/test_pow_field_ref_wiring.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/pow/test_pow_null_missing_infinity.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/query/misc/__init__.py diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/mod/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/mod/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/mod/test_mod_basic_sign.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/mod/test_mod_basic_sign.py new file mode 100644 index 000000000..03b124ab1 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/mod/test_mod_basic_sign.py @@ -0,0 +1,88 @@ +""" +Basic arithmetic and sign-handling tests for $mod expression. + +Covers correct remainder computation across sign combinations of the +dividend and divisor, plus a fractional operand case. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +MOD_BASIC_SIGN_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "remainder_two", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": 10, "divisor": 4}, + expected=2, + msg="Should return correct remainder", + ), + ExpressionTestCase( + "smaller_dividend", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": 5, "divisor": 10}, + expected=5, + msg="Should return dividend when smaller than divisor", + ), + ExpressionTestCase( + "negative_dividend", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": -10, "divisor": 3}, + expected=-1, + msg="Should preserve sign of negative dividend", + ), + ExpressionTestCase( + "negative_divisor", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": 10, "divisor": -3}, + expected=1, + msg="Should return positive remainder for negative divisor", + ), + ExpressionTestCase( + "both_negative", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": -10, "divisor": -3}, + expected=-1, + msg="Should preserve dividend sign when both negative", + ), + ExpressionTestCase( + "zero_dividend", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": 0, "divisor": 5}, + expected=0, + msg="Should return 0 when dividend is zero", + ), + ExpressionTestCase( + "small_fractional", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": 5.5, "divisor": 2.5}, + expected=pytest.approx(0.5), + msg="Should handle small fractional operands", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(MOD_BASIC_SIGN_TESTS)) +def test_mod_literal(collection, test): + """Test $mod from literals""" + result = execute_expression(collection, {"$mod": [test.doc["dividend"], test.doc["divisor"]]}) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +@pytest.mark.parametrize("test", pytest_params(MOD_BASIC_SIGN_TESTS)) +def test_mod_insert(collection, test): + """Test $mod from documents""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/mod/test_mod_bson_type_validation.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/mod/test_mod_bson_type_validation.py new file mode 100644 index 000000000..dcd54b783 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/mod/test_mod_bson_type_validation.py @@ -0,0 +1,87 @@ +""" +BSON type validation tests for $mod expression. + +Verifies that $mod rejects invalid BSON types for its dividend and divisor +input positions, and accepts valid numeric types, via the shared +bson_type_validator harness. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.assertions import assertNotError +from documentdb_tests.framework.bson_type_validator import ( + BsonType, + BsonTypeTestCase, + generate_bson_acceptance_test_cases, + generate_bson_rejection_test_cases, +) +from documentdb_tests.framework.error_codes import MODULO_NON_NUMERIC_ERROR + +# $mod accepts the four numeric types in either position. null is not a type +# error — it propagates to a null result — so it is treated as accepted +# (assertNotError below tolerates the null result). Every other BSON type is +# rejected with MODULO_NON_NUMERIC_ERROR (16611). +NUMERIC_AND_NULL = [ + BsonType.DOUBLE, + BsonType.INT, + BsonType.LONG, + BsonType.DECIMAL, + BsonType.NULL, +] + +MOD_BSON_PARAMS = [ + BsonTypeTestCase( + id="dividend", + msg="$mod dividend should reject non-numeric types", + keyword="dividend", + valid_types=NUMERIC_AND_NULL, + default_error_code=MODULO_NON_NUMERIC_ERROR, + ), + BsonTypeTestCase( + id="divisor", + msg="$mod divisor should reject non-numeric types", + keyword="divisor", + valid_types=NUMERIC_AND_NULL, + default_error_code=MODULO_NON_NUMERIC_ERROR, + ), +] + +REJECTION_CASES = generate_bson_rejection_test_cases(MOD_BSON_PARAMS) +ACCEPTANCE_CASES = generate_bson_acceptance_test_cases(MOD_BSON_PARAMS) + + +def _build_mod_expr(spec, sample_value): + """Build a $mod expression with sample_value in the position under test. + + The sample is injected via a field reference ("$value") so BSON types that + cannot appear as aggregation literals are still exercised. The opposite + operand is a fixed, valid numeric literal. + """ + doc = {"value": sample_value} + if spec.keyword == "dividend": + return {"$mod": ["$value", 3]}, doc + return {"$mod": [10, "$value"]}, doc + + +@pytest.mark.parametrize("bson_type,sample_value,spec", REJECTION_CASES) +def test_mod_bson_type_rejected(collection, bson_type, sample_value, spec): + """Verifies $mod rejects invalid BSON types per input position.""" + expression, doc = _build_mod_expr(spec, sample_value) + result = execute_expression_with_insert(collection, expression, doc) + assert_expression_result( + result, + error_code=spec.expected_code(bson_type), + msg=f"{spec.msg}: {bson_type.value}", + ) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", ACCEPTANCE_CASES) +def test_mod_bson_type_accepted(collection, bson_type, sample_value, spec): + """Verifies $mod accepts valid numeric BSON types (and null) per position.""" + expression, doc = _build_mod_expr(spec, sample_value) + result = execute_expression_with_insert(collection, expression, doc) + assertNotError(result, msg=f"$mod {spec.keyword} should accept {bson_type.value}") diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/mod/test_mod_double_decimal_boundaries.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/mod/test_mod_double_decimal_boundaries.py new file mode 100644 index 000000000..fad0f2f02 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/mod/test_mod_double_decimal_boundaries.py @@ -0,0 +1,122 @@ +""" +Double and Decimal128 boundary tests for $mod expression. + +Covers negative zero sign preservation, infinity divisors, near-max and +min-subnormal doubles, and Decimal128 precision/boundary values (max, min, +large/small exponent) as the dividend. +""" + +import pytest +from bson import Decimal128 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_LARGE_EXPONENT, + DECIMAL128_MAX, + DECIMAL128_MIN, + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_SMALL_EXPONENT, + DOUBLE_MIN_SUBNORMAL, + DOUBLE_NEAR_MAX, + DOUBLE_NEGATIVE_ZERO, + FLOAT_INFINITY, +) + +MOD_DOUBLE_DECIMAL_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "negative_zero_double_dividend", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": DOUBLE_NEGATIVE_ZERO, "divisor": 3}, + expected=DOUBLE_NEGATIVE_ZERO, + msg="Should preserve sign for negative zero double dividend", + ), + ExpressionTestCase( + "negative_zero_decimal_dividend", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": DECIMAL128_NEGATIVE_ZERO, "divisor": Decimal128("3")}, + expected=DECIMAL128_NEGATIVE_ZERO, + msg="Should preserve sign for negative zero decimal128 dividend", + ), + ExpressionTestCase( + "inf_divisor", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": 10, "divisor": FLOAT_INFINITY}, + expected=10.0, + msg="Should return dividend when divisor is infinity", + ), + ExpressionTestCase( + "huge_modulo", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": DOUBLE_NEAR_MAX, "divisor": 7}, + expected=3.0, + msg="Should handle near-max double as dividend", + ), + ExpressionTestCase( + "min_subnormal_modulo", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": DOUBLE_MIN_SUBNORMAL, "divisor": 3}, + expected=DOUBLE_MIN_SUBNORMAL, + msg="Should handle min subnormal double as dividend", + ), + ExpressionTestCase( + "decimal_precision", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": Decimal128("10.5"), "divisor": Decimal128("3.2")}, + expected=Decimal128("0.9"), + msg="Should preserve decimal128 precision", + ), + ExpressionTestCase( + "decimal_max_dividend", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": DECIMAL128_MAX, "divisor": Decimal128("3")}, + expected=Decimal128("0"), + msg="Should handle Decimal128 max value as dividend", + ), + ExpressionTestCase( + "decimal_min_dividend", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": DECIMAL128_MIN, "divisor": Decimal128("3")}, + expected=Decimal128("-0"), + msg="Should handle Decimal128 min value as dividend", + ), + ExpressionTestCase( + "decimal_large_exponent_dividend", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": DECIMAL128_LARGE_EXPONENT, "divisor": Decimal128("3")}, + expected=Decimal128("1"), + msg="Should handle Decimal128 large exponent as dividend", + ), + ExpressionTestCase( + "decimal_small_exponent_dividend", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": DECIMAL128_SMALL_EXPONENT, "divisor": Decimal128("3")}, + expected=DECIMAL128_SMALL_EXPONENT, + msg="Should handle Decimal128 small exponent as dividend", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(MOD_DOUBLE_DECIMAL_BOUNDARY_TESTS)) +def test_mod_literal(collection, test): + """Test $mod from literals""" + result = execute_expression(collection, {"$mod": [test.doc["dividend"], test.doc["divisor"]]}) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +@pytest.mark.parametrize("test", pytest_params(MOD_DOUBLE_DECIMAL_BOUNDARY_TESTS)) +def test_mod_insert(collection, test): + """Test $mod from documents""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/mod/test_mod_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/mod/test_mod_errors.py new file mode 100644 index 000000000..07de8f21e --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/mod/test_mod_errors.py @@ -0,0 +1,174 @@ +""" +Error tests for $mod expression. + +Covers non-numeric operand rejection (array/object), zero-divisor rejection +(int, double, int64, Decimal128, including negative zero), and +argument-count/argument-shape errors. +""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + EXPRESSION_TYPE_MISMATCH_ERROR, + MODULO_DECIMAL128_ZERO_REMAINDER_ERROR, + MODULO_NON_NUMERIC_ERROR, + MODULO_ZERO_REMAINDER_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_NEGATIVE_ZERO, + DOUBLE_NEGATIVE_ZERO, +) + +MOD_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "empty_array_dividend", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": [], "divisor": 3}, + error_code=MODULO_NON_NUMERIC_ERROR, + msg="Should reject empty array dividend", + ), + ExpressionTestCase( + "empty_object_dividend", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": {}, "divisor": 3}, + error_code=MODULO_NON_NUMERIC_ERROR, + msg="Should reject empty object dividend", + ), + ExpressionTestCase( + "empty_array_divisor", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": 10, "divisor": []}, + error_code=MODULO_NON_NUMERIC_ERROR, + msg="Should reject empty array divisor", + ), + ExpressionTestCase( + "empty_object_divisor", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": 10, "divisor": {}}, + error_code=MODULO_NON_NUMERIC_ERROR, + msg="Should reject empty object divisor", + ), + ExpressionTestCase( + "zero_divisor", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": 10, "divisor": 0}, + error_code=MODULO_ZERO_REMAINDER_ERROR, + msg="Should reject modulo by zero int", + ), + ExpressionTestCase( + "zero_divisor_double", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": 10, "divisor": 0.0}, + error_code=MODULO_ZERO_REMAINDER_ERROR, + msg="Should reject modulo by zero double", + ), + ExpressionTestCase( + "zero_divisor_int64", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": 10, "divisor": Int64(0)}, + error_code=MODULO_ZERO_REMAINDER_ERROR, + msg="Should reject modulo by zero int64", + ), + ExpressionTestCase( + "zero_dividend_zero_divisor", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": 0, "divisor": 0}, + error_code=MODULO_ZERO_REMAINDER_ERROR, + msg="Should reject 0 mod 0", + ), + ExpressionTestCase( + "decimal_zero_divisor", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": 10, "divisor": Decimal128("0")}, + error_code=MODULO_DECIMAL128_ZERO_REMAINDER_ERROR, + msg="Should reject modulo by zero decimal128", + ), + ExpressionTestCase( + "decimal_zero_dividend_zero_divisor", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": Decimal128("0"), "divisor": Decimal128("0")}, + error_code=MODULO_DECIMAL128_ZERO_REMAINDER_ERROR, + msg="Should reject decimal 0 mod 0", + ), + ExpressionTestCase( + "negative_zero_double_divisor", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": 10, "divisor": DOUBLE_NEGATIVE_ZERO}, + error_code=MODULO_ZERO_REMAINDER_ERROR, + msg="Should reject modulo by negative zero double", + ), + ExpressionTestCase( + "negative_zero_decimal_divisor", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": 10, "divisor": DECIMAL128_NEGATIVE_ZERO}, + error_code=MODULO_DECIMAL128_ZERO_REMAINDER_ERROR, + msg="Should reject modulo by negative zero decimal128", + ), +] + + +# Arity/argument-shape errors have no fixed dividend/divisor pair and are +# literal-only; there is no meaningful field-path/insert variant for these. +MOD_ARITY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "arity_zero_args", + expression={"$mod": []}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="Should reject $mod with zero arguments", + ), + ExpressionTestCase( + "arity_one_arg", + expression={"$mod": [5]}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="Should reject $mod with a single argument", + ), + ExpressionTestCase( + "arity_three_args", + expression={"$mod": [10, 3, 2]}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="Should reject $mod with three arguments", + ), + ExpressionTestCase( + "arity_non_array", + expression={"$mod": 5}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="Should reject $mod with a non-array operand", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(MOD_ERROR_TESTS)) +def test_mod_literal(collection, test): + """Test $mod from literals""" + result = execute_expression(collection, {"$mod": [test.doc["dividend"], test.doc["divisor"]]}) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +@pytest.mark.parametrize("test", pytest_params(MOD_ERROR_TESTS)) +def test_mod_insert(collection, test): + """Test $mod from documents""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +@pytest.mark.parametrize("test", pytest_params(MOD_ARITY_TESTS)) +def test_mod_arity(collection, test): + """Test $mod argument-count and argument-shape errors""" + result = execute_expression(collection, test.expression) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/mod/test_mod_expression_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/mod/test_mod_expression_types.py new file mode 100644 index 000000000..4eed7808f --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/mod/test_mod_expression_types.py @@ -0,0 +1,76 @@ +""" +Tests for $mod expression type smoke tests. + +Covers expression-operator input, array/object-expression input, composite +array field paths, array-index field paths, and self-nested $mod. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + EXPRESSION_TYPE_MISMATCH_ERROR, + MODULO_NON_NUMERIC_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params + +EXPRESSION_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "expression_operator_dividend", + expression={"$mod": [{"$add": [7, 3]}, 3]}, + doc={}, + expected=1, + msg="$add(7,3)=10 mod 3 = 1", + ), + ExpressionTestCase( + "array_expression_single_arg", + expression={"$mod": [["$x", "$y"]]}, + doc={"x": 10, "y": 3}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="$mod requires exactly two arguments; a single array-expression argument is arity 1", + ), + ExpressionTestCase( + "object_expression_dividend", + expression={"$mod": [{"a": "$x"}, 3]}, + doc={"x": 10}, + error_code=MODULO_NON_NUMERIC_ERROR, + msg="Object expression dividend is rejected as non-numeric", + ), + ExpressionTestCase( + "composite_array_field_path", + expression={"$mod": ["$a.b", 3]}, + doc={"a": [{"b": 1}, {"b": 2}]}, + error_code=MODULO_NON_NUMERIC_ERROR, + msg="Composite array field path resolves to [1,2], a non-numeric array dividend", + ), + ExpressionTestCase( + "array_index_field_path", + expression={"$mod": ["$a.0.b", 3]}, + doc={"a": [{"b": 7}, {"b": 2}]}, + error_code=MODULO_NON_NUMERIC_ERROR, + msg="$a.0.b does not do positional array indexing in this context; " + "it resolves to a non-numeric array dividend", + ), + ExpressionTestCase( + "self_nested_mod", + expression={"$mod": [{"$mod": ["$v", 10]}, 3]}, + doc={"v": 25}, + expected=2, + msg="Self-nested $mod: (25 mod 10)=5, 5 mod 3 = 2", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(EXPRESSION_TYPE_TESTS)) +def test_mod_expression_types(collection, test): + """Test $mod with expression-operator, array/object, and composite field path inputs.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/mod/test_mod_integer_boundaries.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/mod/test_mod_integer_boundaries.py new file mode 100644 index 000000000..27951b4f6 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/mod/test_mod_integer_boundaries.py @@ -0,0 +1,148 @@ +""" +Integer boundary tests for $mod expression. + +Covers INT32/INT64 max/min (and adjacent) values as the dividend, the +INT_MIN mod -1 no-overflow case, and large dividend/divisor combinations. +""" + +import pytest +from bson import Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + INT32_MAX, + INT32_MAX_MINUS_1, + INT32_MIN, + INT32_MIN_PLUS_1, + INT64_MAX, + INT64_MAX_MINUS_1, + INT64_MIN, + INT64_MIN_PLUS_1, +) + +MOD_INTEGER_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int32_max", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": INT32_MAX, "divisor": 10}, + expected=7, + msg="Should handle INT32_MAX as dividend", + ), + ExpressionTestCase( + "int32_max_minus_1", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": INT32_MAX_MINUS_1, "divisor": 10}, + expected=6, + msg="Should handle INT32_MAX-1 as dividend", + ), + ExpressionTestCase( + "int32_min", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": INT32_MIN, "divisor": 10}, + expected=-8, + msg="Should handle INT32_MIN as dividend", + ), + ExpressionTestCase( + "int32_min_plus_1", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": INT32_MIN_PLUS_1, "divisor": 10}, + expected=-7, + msg="Should handle INT32_MIN+1 as dividend", + ), + ExpressionTestCase( + "int64_max", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": INT64_MAX, "divisor": Int64(10)}, + expected=Int64(7), + msg="Should handle INT64_MAX as dividend", + ), + ExpressionTestCase( + "int64_max_minus_1", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": INT64_MAX_MINUS_1, "divisor": Int64(10)}, + expected=Int64(6), + msg="Should handle INT64_MAX-1 as dividend", + ), + ExpressionTestCase( + "int64_min", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": INT64_MIN, "divisor": Int64(10)}, + expected=Int64(-8), + msg="Should handle INT64_MIN as dividend", + ), + ExpressionTestCase( + "int64_min_plus_1", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": INT64_MIN_PLUS_1, "divisor": Int64(10)}, + expected=Int64(-7), + msg="Should handle INT64_MIN+1 as dividend", + ), + ExpressionTestCase( + "int32_min_mod_negative_one", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": INT32_MIN, "divisor": -1}, + expected=0, + msg="Should handle INT32_MIN mod -1 without overflow", + ), + ExpressionTestCase( + "int32_min_plus_1_mod_negative_one", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": INT32_MIN_PLUS_1, "divisor": -1}, + expected=0, + msg="Should handle INT32_MIN+1 mod -1", + ), + ExpressionTestCase( + "int64_min_mod_negative_one", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": INT64_MIN, "divisor": Int64(-1)}, + expected=Int64(0), + msg="Should handle INT64_MIN mod -1 without overflow", + ), + ExpressionTestCase( + "int64_min_plus_1_mod_negative_one", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": INT64_MIN_PLUS_1, "divisor": Int64(-1)}, + expected=Int64(0), + msg="Should handle INT64_MIN+1 mod -1", + ), + ExpressionTestCase( + "million_mod_seven", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": 1000000, "divisor": 7}, + expected=1, + msg="Should handle large dividend", + ), + ExpressionTestCase( + "int64_max_modulo", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": INT64_MAX, "divisor": 1000000}, + expected=Int64(775807), + msg="Should handle INT64_MAX mod large divisor", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(MOD_INTEGER_BOUNDARY_TESTS)) +def test_mod_literal(collection, test): + """Test $mod from literals""" + result = execute_expression(collection, {"$mod": [test.doc["dividend"], test.doc["divisor"]]}) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +@pytest.mark.parametrize("test", pytest_params(MOD_INTEGER_BOUNDARY_TESTS)) +def test_mod_insert(collection, test): + """Test $mod from documents""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/mod/test_mod_nan_infinity.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/mod/test_mod_nan_infinity.py new file mode 100644 index 000000000..21a0c8289 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/mod/test_mod_nan_infinity.py @@ -0,0 +1,172 @@ +""" +NaN and Infinity tests for $mod expression. + +Covers every combination of NaN, Infinity, and -Infinity as the dividend +and/or divisor, for both double and Decimal128. +""" + +import math + +import pytest +from bson import Decimal128 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, +) + +MOD_NAN_INFINITY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "neg_inf_divisor", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": 10, "divisor": FLOAT_NEGATIVE_INFINITY}, + expected=10.0, + msg="Should return dividend when divisor is -infinity", + ), + ExpressionTestCase( + "decimal_neg_inf_divisor", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": 10, "divisor": DECIMAL128_NEGATIVE_INFINITY}, + expected=Decimal128("10"), + msg="Should return dividend when divisor is decimal -infinity", + ), + ExpressionTestCase( + "nan_dividend", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": FLOAT_NAN, "divisor": 3}, + expected=pytest.approx(math.nan, nan_ok=True), + msg="Should return NaN when dividend is NaN", + ), + ExpressionTestCase( + "nan_divisor", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": 10, "divisor": FLOAT_NAN}, + expected=pytest.approx(math.nan, nan_ok=True), + msg="Should return NaN when divisor is NaN", + ), + ExpressionTestCase( + "both_nan", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": FLOAT_NAN, "divisor": FLOAT_NAN}, + expected=pytest.approx(math.nan, nan_ok=True), + msg="Should return NaN when both are NaN", + ), + ExpressionTestCase( + "inf_dividend", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": FLOAT_INFINITY, "divisor": 3}, + expected=pytest.approx(math.nan, nan_ok=True), + msg="Should return NaN for infinity mod finite", + ), + ExpressionTestCase( + "neg_inf_dividend", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": FLOAT_NEGATIVE_INFINITY, "divisor": 3}, + expected=pytest.approx(math.nan, nan_ok=True), + msg="Should return NaN for -infinity mod finite", + ), + ExpressionTestCase( + "decimal_nan_dividend", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": DECIMAL128_NAN, "divisor": 3}, + expected=DECIMAL128_NAN, + msg="Should return decimal NaN when dividend is decimal NaN", + ), + ExpressionTestCase( + "decimal_nan_divisor", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": 10, "divisor": DECIMAL128_NAN}, + expected=DECIMAL128_NAN, + msg="Should return decimal NaN when divisor is decimal NaN", + ), + ExpressionTestCase( + "decimal_inf_dividend", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": DECIMAL128_INFINITY, "divisor": 3}, + expected=DECIMAL128_NAN, + msg="Should return decimal NaN for decimal infinity mod finite", + ), + ExpressionTestCase( + "inf_mod_inf", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": FLOAT_INFINITY, "divisor": FLOAT_INFINITY}, + expected=pytest.approx(math.nan, nan_ok=True), + msg="Should return NaN for infinity mod infinity", + ), + ExpressionTestCase( + "neg_inf_mod_neg_inf", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": FLOAT_NEGATIVE_INFINITY, "divisor": FLOAT_NEGATIVE_INFINITY}, + expected=pytest.approx(math.nan, nan_ok=True), + msg="Should return NaN for -infinity mod -infinity", + ), + ExpressionTestCase( + "inf_mod_neg_inf", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": FLOAT_INFINITY, "divisor": FLOAT_NEGATIVE_INFINITY}, + expected=pytest.approx(math.nan, nan_ok=True), + msg="Should return NaN for infinity mod -infinity", + ), + ExpressionTestCase( + "neg_inf_mod_inf", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": FLOAT_NEGATIVE_INFINITY, "divisor": FLOAT_INFINITY}, + expected=pytest.approx(math.nan, nan_ok=True), + msg="Should return NaN for -infinity mod infinity", + ), + ExpressionTestCase( + "decimal_inf_mod_inf", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": DECIMAL128_INFINITY, "divisor": DECIMAL128_INFINITY}, + expected=DECIMAL128_NAN, + msg="Should return decimal NaN for decimal infinity mod decimal infinity", + ), + ExpressionTestCase( + "decimal_neg_inf_mod_neg_inf", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={ + "dividend": DECIMAL128_NEGATIVE_INFINITY, + "divisor": DECIMAL128_NEGATIVE_INFINITY, + }, + expected=DECIMAL128_NAN, + msg="Should return decimal NaN for decimal -infinity mod decimal -infinity", + ), + ExpressionTestCase( + "decimal_inf_mod_neg_inf", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": DECIMAL128_INFINITY, "divisor": DECIMAL128_NEGATIVE_INFINITY}, + expected=DECIMAL128_NAN, + msg="Should return decimal NaN for decimal infinity mod decimal -infinity", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(MOD_NAN_INFINITY_TESTS)) +def test_mod_literal(collection, test): + """Test $mod from literals""" + result = execute_expression(collection, {"$mod": [test.doc["dividend"], test.doc["divisor"]]}) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +@pytest.mark.parametrize("test", pytest_params(MOD_NAN_INFINITY_TESTS)) +def test_mod_insert(collection, test): + """Test $mod from documents""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/mod/test_mod_null_missing.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/mod/test_mod_null_missing.py new file mode 100644 index 000000000..5025022cc --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/mod/test_mod_null_missing.py @@ -0,0 +1,86 @@ +""" +Null and missing operand tests for $mod expression. + +Covers null/missing short-circuiting to a null result in each operand +position, independent of the other operand's value. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + MISSING, +) + +MOD_NULL_MISSING_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_divisor", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": 10, "divisor": None}, + expected=None, + msg="Should return null when divisor is null", + ), + ExpressionTestCase( + "null_dividend", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": None, "divisor": 3}, + expected=None, + msg="Should return null when dividend is null", + ), + ExpressionTestCase( + "missing_dividend", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": MISSING, "divisor": 3}, + expected=None, + msg="Should return null when dividend is missing", + ), + ExpressionTestCase( + "missing_divisor", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": 10, "divisor": MISSING}, + expected=None, + msg="Should return null when divisor is missing", + ), + ExpressionTestCase( + "both_null", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": None, "divisor": None}, + expected=None, + msg="Should return null when both are null", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(MOD_NULL_MISSING_TESTS)) +def test_mod_literal(collection, test): + """Test $mod from literals""" + result = execute_expression(collection, {"$mod": [test.doc["dividend"], test.doc["divisor"]]}) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +@pytest.mark.parametrize("test", pytest_params(MOD_NULL_MISSING_TESTS)) +def test_mod_insert(collection, test): + """Test $mod from documents. + + A MISSING dividend/divisor is represented by omitting that key from the + inserted document entirely, rather than inserting a MISSING sentinel value. + """ + doc = {} + if test.doc["dividend"] != MISSING: + doc["dividend"] = test.doc["dividend"] + if test.doc["divisor"] != MISSING: + doc["divisor"] = test.doc["divisor"] + result = execute_expression_with_insert(collection, test.expression, doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/mod/test_mod_rounding_halves.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/mod/test_mod_rounding_halves.py new file mode 100644 index 000000000..481bc0a0b --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/mod/test_mod_rounding_halves.py @@ -0,0 +1,74 @@ +""" +Rounding-precision tests for $mod expression near the 0.5 boundary. + +Verifies double and Decimal128 precision is preserved for dividends just +below and just above a half value. +""" + +import pytest +from bson import Decimal128 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_JUST_ABOVE_HALF, + DECIMAL128_JUST_BELOW_HALF, + DOUBLE_JUST_ABOVE_HALF, + DOUBLE_JUST_BELOW_HALF, +) + +MOD_ROUNDING_HALVES_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "just_below_half_mod_one", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": DOUBLE_JUST_BELOW_HALF, "divisor": 1}, + expected=pytest.approx(0.4999999999999994), + msg="Should preserve precision near 0.5 boundary", + ), + ExpressionTestCase( + "just_above_half_mod_one", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": DOUBLE_JUST_ABOVE_HALF, "divisor": 1}, + expected=pytest.approx(0.500000001), + msg="Should preserve precision just above 0.5", + ), + ExpressionTestCase( + "decimal_just_below_half_mod_one", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": DECIMAL128_JUST_BELOW_HALF, "divisor": Decimal128("1")}, + expected=Decimal128("0.4999999999999999999999999999999999"), + msg="Should preserve decimal precision near 0.5", + ), + ExpressionTestCase( + "decimal_just_above_half_mod_one", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": DECIMAL128_JUST_ABOVE_HALF, "divisor": Decimal128("1")}, + expected=Decimal128("0.5000000000000000000000000000000001"), + msg="Should preserve decimal precision just above 0.5", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(MOD_ROUNDING_HALVES_TESTS)) +def test_mod_literal(collection, test): + """Test $mod from literals""" + result = execute_expression(collection, {"$mod": [test.doc["dividend"], test.doc["divisor"]]}) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +@pytest.mark.parametrize("test", pytest_params(MOD_ROUNDING_HALVES_TESTS)) +def test_mod_insert(collection, test): + """Test $mod from documents""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/mod/test_mod_type_matrix.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/mod/test_mod_type_matrix.py new file mode 100644 index 000000000..1e4488bce --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/mod/test_mod_type_matrix.py @@ -0,0 +1,110 @@ +""" +Numeric type matrix tests for $mod expression. + +Covers $mod across every same-type and cross-type pairing of the four +numeric BSON types (int32, int64, double, decimal128). +""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +MOD_TYPE_MATRIX_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "same_type_int32", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": 10, "divisor": 3}, + expected=1, + msg="Should compute modulo of int32 values", + ), + ExpressionTestCase( + "same_type_int64", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": Int64(10), "divisor": Int64(3)}, + expected=Int64(1), + msg="Should compute modulo of int64 values", + ), + ExpressionTestCase( + "same_type_double", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": 10.5, "divisor": 3.0}, + expected=1.5, + msg="Should compute modulo of double values", + ), + ExpressionTestCase( + "same_type_decimal", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": Decimal128("10.5"), "divisor": Decimal128("3")}, + expected=Decimal128("1.5"), + msg="Should compute modulo of decimal128 values", + ), + ExpressionTestCase( + "int32_int64", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": 10, "divisor": Int64(3)}, + expected=Int64(1), + msg="Should compute modulo of int32 by int64", + ), + ExpressionTestCase( + "int32_double", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": 10, "divisor": 3.0}, + expected=1.0, + msg="Should compute modulo of int32 by double", + ), + ExpressionTestCase( + "int32_decimal", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": 10, "divisor": Decimal128("3")}, + expected=Decimal128("1"), + msg="Should compute modulo of int32 by decimal128", + ), + ExpressionTestCase( + "int64_double", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": Int64(10), "divisor": 3.0}, + expected=1.0, + msg="Should compute modulo of int64 by double", + ), + ExpressionTestCase( + "int64_decimal", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": Int64(10), "divisor": Decimal128("3")}, + expected=Decimal128("1"), + msg="Should compute modulo of int64 by decimal128", + ), + ExpressionTestCase( + "double_decimal", + expression={"$mod": ["$dividend", "$divisor"]}, + doc={"dividend": 10.5, "divisor": Decimal128("3")}, + expected=Decimal128("1.5000000000000"), + msg="Should compute modulo of double by decimal128", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(MOD_TYPE_MATRIX_TESTS)) +def test_mod_literal(collection, test): + """Test $mod from literals""" + result = execute_expression(collection, {"$mod": [test.doc["dividend"], test.doc["divisor"]]}) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +@pytest.mark.parametrize("test", pytest_params(MOD_TYPE_MATRIX_TESTS)) +def test_mod_insert(collection, test): + """Test $mod from documents""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/multiply/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/multiply/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/multiply/test_multiply_boundaries.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/multiply/test_multiply_boundaries.py new file mode 100644 index 000000000..08d7f60de --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/multiply/test_multiply_boundaries.py @@ -0,0 +1,372 @@ +""" +Overflow and boundary tests for $multiply expression. + +Covers INT32/INT64 max/min (and adjacent) values through the +int32->int64->double promotion chain, double overflow/underflow near the +double range limits (including subnormals), and Decimal128 +precision/overflow at its boundary values. +""" + +import pytest +from bson import ( + Decimal128, + Int64, +) + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_LARGE_EXPONENT, + DECIMAL128_MAX, + DECIMAL128_SMALL_EXPONENT, + DOUBLE_MIN_NEGATIVE_SUBNORMAL, + DOUBLE_MIN_SUBNORMAL, + DOUBLE_NEAR_MAX, + DOUBLE_NEAR_MIN, + INT32_MAX, + INT32_MAX_MINUS_1, + INT32_MIN, + INT32_MIN_PLUS_1, + INT64_MAX, + INT64_MAX_MINUS_1, + INT64_MIN, + INT64_MIN_PLUS_1, +) + +MULTIPLY_LITERAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int32_overflow", + expression={"$multiply": [INT32_MAX, 2]}, + expected=Int64(4294967294), + msg="Should handle int32 overflow", + ), + ExpressionTestCase( + "int32_max_minus_1_times_2", + expression={"$multiply": [INT32_MAX_MINUS_1, 2]}, + expected=Int64(4294967292), + msg="Should handle int32 max minus 1 times 2", + ), + ExpressionTestCase( + "int32_underflow", + expression={"$multiply": [INT32_MIN, 2]}, + expected=Int64(-4294967296), + msg="Should handle int32 underflow", + ), + ExpressionTestCase( + "int32_min_plus_1_times_2", + expression={"$multiply": [INT32_MIN_PLUS_1, 2]}, + expected=Int64(-4294967294), + msg="Should handle int32 min plus 1 times 2", + ), + ExpressionTestCase( + "int64_overflow", + expression={"$multiply": [INT64_MAX, 2]}, + expected=pytest.approx(1.8446744073709552e19), + msg="Should handle int64 overflow", + ), + ExpressionTestCase( + "int64_max_minus_1_times_2", + expression={"$multiply": [INT64_MAX_MINUS_1, 2]}, + expected=pytest.approx(1.8446744073709552e19), + msg="Should handle int64 max minus 1 times 2", + ), + ExpressionTestCase( + "int64_underflow", + expression={"$multiply": [INT64_MIN, 2]}, + expected=pytest.approx(-1.8446744073709552e19), + msg="Should handle int64 underflow", + ), + ExpressionTestCase( + "int64_min_plus_1_times_2", + expression={"$multiply": [INT64_MIN_PLUS_1, 2]}, + expected=pytest.approx(-1.8446744073709552e19), + msg="Should handle int64 min plus 1 times 2", + ), + ExpressionTestCase( + "double_overflow", + expression={"$multiply": [DOUBLE_NEAR_MAX, 10]}, + expected=float("inf"), + msg="Should handle double overflow", + ), + ExpressionTestCase( + "double_underflow", + expression={"$multiply": [-DOUBLE_NEAR_MAX, 10]}, + expected=float("-inf"), + msg="Should handle double underflow", + ), + ExpressionTestCase( + "decimal_precision", + expression={"$multiply": [Decimal128("1.5"), Decimal128("2.5")]}, + expected=Decimal128("3.75"), + msg="Should preserve precision for decimal precision", + ), + ExpressionTestCase( + "decimal_precision_small", + expression={"$multiply": [Decimal128("0.1"), Decimal128("0.2")]}, + expected=Decimal128("0.02"), + msg="Should preserve precision for decimal precision small", + ), + ExpressionTestCase( + "decimal_large_precision", + expression={ + "$multiply": [Decimal128("99999999999999999"), Decimal128("99999999999999999")] + }, + expected=Decimal128("9999999999999999800000000000000001"), + msg="Should preserve precision for decimal large precision", + ), + ExpressionTestCase( + "decimal_overflow_to_infinity", + expression={"$multiply": [DECIMAL128_MAX, 2]}, + expected=DECIMAL128_INFINITY, + msg="Should handle decimal overflow to infinity", + ), + ExpressionTestCase( + "decimal_max_times_one", + expression={"$multiply": [DECIMAL128_MAX, 1]}, + expected=DECIMAL128_MAX, + msg="Should preserve decimal max value when multiplied by one", + ), + ExpressionTestCase( + "decimal_small_exponent_times_one", + expression={"$multiply": [DECIMAL128_SMALL_EXPONENT, 1]}, + expected=DECIMAL128_SMALL_EXPONENT, + msg="Should preserve decimal small exponent value when multiplied by one", + ), + ExpressionTestCase( + "decimal_large_exponent_times_one", + expression={"$multiply": [DECIMAL128_LARGE_EXPONENT, 1]}, + expected=Decimal128("1.000000000000000000000000000000000E+6144"), + msg="Should preserve decimal large exponent value when multiplied by one", + ), + ExpressionTestCase( + "double_min_subnormal_times_two", + expression={"$multiply": [DOUBLE_MIN_SUBNORMAL, 2]}, + expected=pytest.approx(1e-323), + msg="Should handle double min subnormal times two", + ), + ExpressionTestCase( + "double_min_negative_subnormal_times_two", + expression={"$multiply": [DOUBLE_MIN_NEGATIVE_SUBNORMAL, 2]}, + expected=pytest.approx(-1e-323), + msg="Should handle double min negative subnormal times two", + ), + ExpressionTestCase( + "double_near_min_underflow_to_zero", + expression={"$multiply": [DOUBLE_NEAR_MIN, 0.0001]}, + expected=pytest.approx(1e-312), + msg="Should handle double near-min underflow toward zero", + ), +] + + +MULTIPLY_FIELD_REF_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int32_overflow", + expression={"$multiply": ["$val0", "$val1"]}, + doc={"val0": INT32_MAX, "val1": 2}, + expected=Int64(4294967294), + msg="Should handle int32 overflow", + ), + ExpressionTestCase( + "int32_max_minus_1_times_2", + expression={"$multiply": ["$val0", "$val1"]}, + doc={"val0": INT32_MAX_MINUS_1, "val1": 2}, + expected=Int64(4294967292), + msg="Should handle int32 max minus 1 times 2", + ), + ExpressionTestCase( + "int32_underflow", + expression={"$multiply": ["$val0", "$val1"]}, + doc={"val0": INT32_MIN, "val1": 2}, + expected=Int64(-4294967296), + msg="Should handle int32 underflow", + ), + ExpressionTestCase( + "int32_min_plus_1_times_2", + expression={"$multiply": ["$val0", "$val1"]}, + doc={"val0": INT32_MIN_PLUS_1, "val1": 2}, + expected=Int64(-4294967294), + msg="Should handle int32 min plus 1 times 2", + ), + ExpressionTestCase( + "int64_overflow", + expression={"$multiply": ["$val0", "$val1"]}, + doc={"val0": INT64_MAX, "val1": 2}, + expected=pytest.approx(1.8446744073709552e19), + msg="Should handle int64 overflow", + ), + ExpressionTestCase( + "int64_max_minus_1_times_2", + expression={"$multiply": ["$val0", "$val1"]}, + doc={"val0": INT64_MAX_MINUS_1, "val1": 2}, + expected=pytest.approx(1.8446744073709552e19), + msg="Should handle int64 max minus 1 times 2", + ), + ExpressionTestCase( + "int64_underflow", + expression={"$multiply": ["$val0", "$val1"]}, + doc={"val0": INT64_MIN, "val1": 2}, + expected=pytest.approx(-1.8446744073709552e19), + msg="Should handle int64 underflow", + ), + ExpressionTestCase( + "int64_min_plus_1_times_2", + expression={"$multiply": ["$val0", "$val1"]}, + doc={"val0": INT64_MIN_PLUS_1, "val1": 2}, + expected=pytest.approx(-1.8446744073709552e19), + msg="Should handle int64 min plus 1 times 2", + ), + ExpressionTestCase( + "double_overflow", + expression={"$multiply": ["$val0", "$val1"]}, + doc={"val0": DOUBLE_NEAR_MAX, "val1": 10}, + expected=float("inf"), + msg="Should handle double overflow", + ), + ExpressionTestCase( + "double_underflow", + expression={"$multiply": ["$val0", "$val1"]}, + doc={"val0": -DOUBLE_NEAR_MAX, "val1": 10}, + expected=float("-inf"), + msg="Should handle double underflow", + ), + ExpressionTestCase( + "decimal_precision", + expression={"$multiply": ["$val0", "$val1"]}, + doc={"val0": Decimal128("1.5"), "val1": Decimal128("2.5")}, + expected=Decimal128("3.75"), + msg="Should preserve precision for decimal precision", + ), + ExpressionTestCase( + "decimal_precision_small", + expression={"$multiply": ["$val0", "$val1"]}, + doc={"val0": Decimal128("0.1"), "val1": Decimal128("0.2")}, + expected=Decimal128("0.02"), + msg="Should preserve precision for decimal precision small", + ), + ExpressionTestCase( + "decimal_large_precision", + expression={"$multiply": ["$val0", "$val1"]}, + doc={"val0": Decimal128("99999999999999999"), "val1": Decimal128("99999999999999999")}, + expected=Decimal128("9999999999999999800000000000000001"), + msg="Should preserve precision for decimal large precision", + ), + ExpressionTestCase( + "int32_overflow_mixed", + expression={"$multiply": ["$val0", 2]}, + doc={"val0": INT32_MAX}, + expected=Int64(4294967294), + msg="Should handle int32 overflow", + ), + ExpressionTestCase( + "int32_max_minus_1_times_2_mixed", + expression={"$multiply": ["$val0", 2]}, + doc={"val0": INT32_MAX_MINUS_1}, + expected=Int64(4294967292), + msg="Should handle int32 max minus 1 times 2", + ), + ExpressionTestCase( + "int32_underflow_mixed", + expression={"$multiply": ["$val0", 2]}, + doc={"val0": INT32_MIN}, + expected=Int64(-4294967296), + msg="Should handle int32 underflow", + ), + ExpressionTestCase( + "int32_min_plus_1_times_2_mixed", + expression={"$multiply": ["$val0", 2]}, + doc={"val0": INT32_MIN_PLUS_1}, + expected=Int64(-4294967294), + msg="Should handle int32 min plus 1 times 2", + ), + ExpressionTestCase( + "int64_overflow_mixed", + expression={"$multiply": ["$val0", 2]}, + doc={"val0": INT64_MAX}, + expected=pytest.approx(1.8446744073709552e19), + msg="Should handle int64 overflow", + ), + ExpressionTestCase( + "int64_max_minus_1_times_2_mixed", + expression={"$multiply": ["$val0", 2]}, + doc={"val0": INT64_MAX_MINUS_1}, + expected=pytest.approx(1.8446744073709552e19), + msg="Should handle int64 max minus 1 times 2", + ), + ExpressionTestCase( + "int64_underflow_mixed", + expression={"$multiply": ["$val0", 2]}, + doc={"val0": INT64_MIN}, + expected=pytest.approx(-1.8446744073709552e19), + msg="Should handle int64 underflow", + ), + ExpressionTestCase( + "int64_min_plus_1_times_2_mixed", + expression={"$multiply": ["$val0", 2]}, + doc={"val0": INT64_MIN_PLUS_1}, + expected=pytest.approx(-1.8446744073709552e19), + msg="Should handle int64 min plus 1 times 2", + ), + ExpressionTestCase( + "double_overflow_mixed", + expression={"$multiply": ["$val0", 10]}, + doc={"val0": DOUBLE_NEAR_MAX}, + expected=float("inf"), + msg="Should handle double overflow", + ), + ExpressionTestCase( + "double_underflow_mixed", + expression={"$multiply": ["$val0", 10]}, + doc={"val0": -DOUBLE_NEAR_MAX}, + expected=float("-inf"), + msg="Should handle double underflow", + ), + ExpressionTestCase( + "decimal_precision_mixed", + expression={"$multiply": ["$val0", Decimal128("2.5")]}, + doc={"val0": Decimal128("1.5")}, + expected=Decimal128("3.75"), + msg="Should preserve precision for decimal precision", + ), + ExpressionTestCase( + "decimal_precision_small_mixed", + expression={"$multiply": ["$val0", Decimal128("0.2")]}, + doc={"val0": Decimal128("0.1")}, + expected=Decimal128("0.02"), + msg="Should preserve precision for decimal precision small", + ), + ExpressionTestCase( + "decimal_large_precision_mixed", + expression={"$multiply": ["$val0", Decimal128("99999999999999999")]}, + doc={"val0": Decimal128("99999999999999999")}, + expected=Decimal128("9999999999999999800000000000000001"), + msg="Should preserve precision for decimal large precision", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(MULTIPLY_LITERAL_TESTS)) +def test_multiply_literal(collection, test): + """Test $multiply from literals""" + result = execute_expression(collection, test.expression) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +@pytest.mark.parametrize("test", pytest_params(MULTIPLY_FIELD_REF_TESTS)) +def test_multiply_field_ref(collection, test): + """Test $multiply from documents, using all-field-reference and mixed + literal/field-reference operand forms.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/multiply/test_multiply_bson_type_validation.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/multiply/test_multiply_bson_type_validation.py new file mode 100644 index 000000000..731b728d0 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/multiply/test_multiply_bson_type_validation.py @@ -0,0 +1,83 @@ +""" +BSON type validation tests for $multiply expression. + +Verifies that $multiply rejects invalid BSON types for its numeric operands +and accepts valid numeric types, via the shared bson_type_validator harness. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.assertions import assertNotError +from documentdb_tests.framework.bson_type_validator import ( + BsonType, + BsonTypeTestCase, + generate_bson_acceptance_test_cases, + generate_bson_rejection_test_cases, +) +from documentdb_tests.framework.error_codes import TYPE_MISMATCH_ERROR + +NUMERIC_AND_NULL = [ + BsonType.DOUBLE, + BsonType.INT, + BsonType.LONG, + BsonType.DECIMAL, + BsonType.NULL, +] + +MULTIPLY_BSON_PARAMS = [ + BsonTypeTestCase( + id="operand", + msg="$multiply should reject non-numeric operands", + keyword="operand", + valid_types=NUMERIC_AND_NULL, + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="single_operand_literal", + msg="$multiply should reject non-numeric single literal operand", + keyword="single_operand_literal", + valid_types=NUMERIC_AND_NULL, + default_error_code=TYPE_MISMATCH_ERROR, + ), +] + +REJECTION_CASES = generate_bson_rejection_test_cases(MULTIPLY_BSON_PARAMS) +ACCEPTANCE_CASES = generate_bson_acceptance_test_cases(MULTIPLY_BSON_PARAMS) + + +def _build_multiply_expr(spec, sample_value): + """Build a $multiply expression exercising the given operand shape. + + "operand": two operands via field reference (the value under test plus a + fixed valid literal) — exercises the arity-2, field-ref evaluation path. + "single_operand_literal": a single operand embedded directly as a literal + in the expression (no field reference, no document) — exercises the + arity-1, literal-parsing path, which "operand" cannot reach. + """ + if spec.id == "single_operand_literal": + return {"$multiply": [sample_value]}, {} + return {"$multiply": ["$value", 2]}, {"value": sample_value} + + +@pytest.mark.parametrize("bson_type,sample_value,spec", REJECTION_CASES) +def test_multiply_bson_type_rejected(collection, bson_type, sample_value, spec): + """Verifies $multiply rejects invalid BSON types for an operand.""" + expression, doc = _build_multiply_expr(spec, sample_value) + result = execute_expression_with_insert(collection, expression, doc) + assert_expression_result( + result, + error_code=spec.expected_code(bson_type), + msg=f"{spec.msg}: {bson_type.value}", + ) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", ACCEPTANCE_CASES) +def test_multiply_bson_type_accepted(collection, bson_type, sample_value, spec): + """Verifies $multiply accepts valid numeric BSON types (and null).""" + expression, doc = _build_multiply_expr(spec, sample_value) + result = execute_expression_with_insert(collection, expression, doc) + assertNotError(result, msg=f"$multiply should accept {bson_type.value}") diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/multiply/test_multiply_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/multiply/test_multiply_errors.py new file mode 100644 index 000000000..caf70da10 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/multiply/test_multiply_errors.py @@ -0,0 +1,105 @@ +""" +Error tests for $multiply expression. + +Covers non-numeric operand rejection (empty array, empty object) and a +mixed valid/invalid operand list. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import TYPE_MISMATCH_ERROR +from documentdb_tests.framework.parametrize import pytest_params + +MULTIPLY_LITERAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "empty_array", + expression={"$multiply": [2, []]}, + error_code=TYPE_MISMATCH_ERROR, + msg="Should reject empty array", + ), + ExpressionTestCase( + "empty_object", + expression={"$multiply": [2, {}]}, + error_code=TYPE_MISMATCH_ERROR, + msg="Should reject empty object", + ), + ExpressionTestCase( + "mixed_valid_invalid", + expression={"$multiply": [2, 3, "string"]}, + error_code=TYPE_MISMATCH_ERROR, + msg="Should reject mixed valid invalid", + ), +] + + +MULTIPLY_FIELD_REF_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "empty_array", + expression={"$multiply": ["$val0", "$val1"]}, + doc={"val0": 2, "val1": []}, + error_code=TYPE_MISMATCH_ERROR, + msg="Should reject empty array", + ), + ExpressionTestCase( + "empty_object", + expression={"$multiply": ["$val0", "$val1"]}, + doc={"val0": 2, "val1": {}}, + error_code=TYPE_MISMATCH_ERROR, + msg="Should reject empty object", + ), + ExpressionTestCase( + "mixed_valid_invalid", + expression={"$multiply": ["$val0", "$val1", "$val2"]}, + doc={"val0": 2, "val1": 3, "val2": "string"}, + error_code=TYPE_MISMATCH_ERROR, + msg="Should reject mixed valid invalid", + ), + ExpressionTestCase( + "empty_array_mixed", + expression={"$multiply": ["$val0", []]}, + doc={"val0": 2}, + error_code=TYPE_MISMATCH_ERROR, + msg="Should reject empty array", + ), + ExpressionTestCase( + "empty_object_mixed", + expression={"$multiply": ["$val0", {}]}, + doc={"val0": 2}, + error_code=TYPE_MISMATCH_ERROR, + msg="Should reject empty object", + ), + ExpressionTestCase( + "mixed_valid_invalid_mixed", + expression={"$multiply": ["$val0", 3, "string"]}, + doc={"val0": 2}, + error_code=TYPE_MISMATCH_ERROR, + msg="Should reject mixed valid invalid", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(MULTIPLY_LITERAL_TESTS)) +def test_multiply_literal(collection, test): + """Test $multiply from literals""" + result = execute_expression(collection, test.expression) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +@pytest.mark.parametrize("test", pytest_params(MULTIPLY_FIELD_REF_TESTS)) +def test_multiply_field_ref(collection, test): + """Test $multiply from documents, using all-field-reference and mixed + literal/field-reference operand forms.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/multiply/test_multiply_expression_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/multiply/test_multiply_expression_types.py new file mode 100644 index 000000000..66b6c5395 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/multiply/test_multiply_expression_types.py @@ -0,0 +1,62 @@ +""" +Tests for $multiply expression type smoke tests. + +Covers array-expression input, object-expression input, composite array field +paths, and array-index field paths per TEST_COVERAGE §3 (per-operator +expression-type checklist). +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import TYPE_MISMATCH_ERROR +from documentdb_tests.framework.parametrize import pytest_params + +EXPRESSION_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "array_expression_single_operand", + expression={"$multiply": [["$x", "$y"]]}, + doc={"x": 10, "y": 3}, + error_code=TYPE_MISMATCH_ERROR, + msg="Array literal containing field-path expressions resolves to a " + "non-numeric array operand", + ), + ExpressionTestCase( + "object_expression_single_operand", + expression={"$multiply": [{"a": "$x"}]}, + doc={"x": 10}, + error_code=TYPE_MISMATCH_ERROR, + msg="Object literal containing a field-path expression resolves to a " + "non-numeric object operand", + ), + ExpressionTestCase( + "composite_array_field_path", + expression={"$multiply": ["$a.b", 1]}, + doc={"a": [{"b": 2}, {"b": 3}]}, + error_code=TYPE_MISMATCH_ERROR, + msg="Composite array field path resolves to [2,3], a non-numeric array operand", + ), + ExpressionTestCase( + "array_index_field_path", + expression={"$multiply": ["$a.0.b", 3]}, + doc={"a": [{"b": 7}, {"b": 2}]}, + error_code=TYPE_MISMATCH_ERROR, + msg="$a.0.b does not do positional array indexing in this context; " + "it resolves to a non-numeric array operand", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(EXPRESSION_TYPE_TESTS)) +def test_multiply_expression_types(collection, test): + """Test $multiply with composite array field path and array-index path inputs.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/multiply/test_multiply_infinity_nan.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/multiply/test_multiply_infinity_nan.py new file mode 100644 index 000000000..3a53c063d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/multiply/test_multiply_infinity_nan.py @@ -0,0 +1,394 @@ +""" +NaN and Infinity tests for $multiply expression. + +Covers every combination of NaN, Infinity, and -Infinity as operands, for +both double and Decimal128. +""" + +import math + +import pytest +from bson import Decimal128 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, +) + +MULTIPLY_LITERAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "infinity", + expression={"$multiply": [FLOAT_INFINITY, 2]}, + expected=float("inf"), + msg="Should handle infinity", + ), + ExpressionTestCase( + "negative_infinity", + expression={"$multiply": [FLOAT_NEGATIVE_INFINITY, 2]}, + expected=float("-inf"), + msg="Should handle negative infinity", + ), + ExpressionTestCase( + "single_infinity", + expression={"$multiply": [FLOAT_INFINITY]}, + expected=float("inf"), + msg="Should handle single infinity", + ), + ExpressionTestCase( + "decimal_infinity", + expression={"$multiply": [DECIMAL128_INFINITY, 2]}, + expected=Decimal128("Infinity"), + msg="Should handle decimal infinity", + ), + ExpressionTestCase( + "decimal_negative_infinity", + expression={"$multiply": [DECIMAL128_NEGATIVE_INFINITY, 2]}, + expected=Decimal128("-Infinity"), + msg="Should handle decimal negative infinity", + ), + ExpressionTestCase( + "inf_times_inf", + expression={"$multiply": [FLOAT_INFINITY, FLOAT_INFINITY]}, + expected=float("inf"), + msg="Should handle inf times inf", + ), + ExpressionTestCase( + "neg_inf_times_neg_inf", + expression={"$multiply": [FLOAT_NEGATIVE_INFINITY, FLOAT_NEGATIVE_INFINITY]}, + expected=float("inf"), + msg="Should handle neg inf times neg inf", + ), + ExpressionTestCase( + "inf_times_negative", + expression={"$multiply": [FLOAT_INFINITY, -1]}, + expected=float("-inf"), + msg="Should handle inf times negative", + ), + ExpressionTestCase( + "neg_inf_times_negative", + expression={"$multiply": [FLOAT_NEGATIVE_INFINITY, -1]}, + expected=float("inf"), + msg="Should handle neg inf times negative", + ), + ExpressionTestCase( + "nan_multiply_two", + expression={"$multiply": [FLOAT_NAN, 2]}, + expected=pytest.approx(math.nan, nan_ok=True), + msg="Should return NaN for nan multiply two", + ), + ExpressionTestCase( + "inf_times_zero", + expression={"$multiply": [FLOAT_INFINITY, 0]}, + expected=pytest.approx(math.nan, nan_ok=True), + msg="Should handle inf times zero", + ), + ExpressionTestCase( + "neg_inf_times_zero", + expression={"$multiply": [FLOAT_NEGATIVE_INFINITY, 0]}, + expected=pytest.approx(math.nan, nan_ok=True), + msg="Should handle neg inf times zero", + ), + ExpressionTestCase( + "nan_times_nan", + expression={"$multiply": [FLOAT_NAN, FLOAT_NAN]}, + expected=pytest.approx(math.nan, nan_ok=True), + msg="Should return NaN for nan times nan", + ), + ExpressionTestCase( + "decimal_nan_times_nan", + expression={"$multiply": [DECIMAL128_NAN, DECIMAL128_NAN]}, + expected=DECIMAL128_NAN, + msg="Should return NaN for decimal nan times nan", + ), + ExpressionTestCase( + "nan_times_inf", + expression={"$multiply": [FLOAT_NAN, FLOAT_INFINITY]}, + expected=pytest.approx(math.nan, nan_ok=True), + msg="Should return NaN for nan times inf", + ), + ExpressionTestCase( + "decimal_nan", + expression={"$multiply": [DECIMAL128_NAN, 2]}, + expected=DECIMAL128_NAN, + msg="Should return NaN for decimal nan", + ), + ExpressionTestCase( + "decimal_inf_times_zero", + expression={"$multiply": [DECIMAL128_INFINITY, 0]}, + expected=DECIMAL128_NAN, + msg="Should handle decimal inf times zero", + ), + ExpressionTestCase( + "nan_times_zero", + expression={"$multiply": [FLOAT_NAN, 0]}, + expected=pytest.approx(math.nan, nan_ok=True), + msg="Should return NaN for nan times zero", + ), +] + + +MULTIPLY_FIELD_REF_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "infinity", + expression={"$multiply": ["$val0", "$val1"]}, + doc={"val0": FLOAT_INFINITY, "val1": 2}, + expected=float("inf"), + msg="Should handle infinity", + ), + ExpressionTestCase( + "negative_infinity", + expression={"$multiply": ["$val0", "$val1"]}, + doc={"val0": FLOAT_NEGATIVE_INFINITY, "val1": 2}, + expected=float("-inf"), + msg="Should handle negative infinity", + ), + ExpressionTestCase( + "single_infinity", + expression={"$multiply": ["$val0"]}, + doc={"val0": FLOAT_INFINITY}, + expected=float("inf"), + msg="Should handle single infinity", + ), + ExpressionTestCase( + "decimal_infinity", + expression={"$multiply": ["$val0", "$val1"]}, + doc={"val0": DECIMAL128_INFINITY, "val1": 2}, + expected=Decimal128("Infinity"), + msg="Should handle decimal infinity", + ), + ExpressionTestCase( + "decimal_negative_infinity", + expression={"$multiply": ["$val0", "$val1"]}, + doc={"val0": DECIMAL128_NEGATIVE_INFINITY, "val1": 2}, + expected=Decimal128("-Infinity"), + msg="Should handle decimal negative infinity", + ), + ExpressionTestCase( + "inf_times_inf", + expression={"$multiply": ["$val0", "$val1"]}, + doc={"val0": FLOAT_INFINITY, "val1": FLOAT_INFINITY}, + expected=float("inf"), + msg="Should handle inf times inf", + ), + ExpressionTestCase( + "neg_inf_times_neg_inf", + expression={"$multiply": ["$val0", "$val1"]}, + doc={"val0": FLOAT_NEGATIVE_INFINITY, "val1": FLOAT_NEGATIVE_INFINITY}, + expected=float("inf"), + msg="Should handle neg inf times neg inf", + ), + ExpressionTestCase( + "inf_times_negative", + expression={"$multiply": ["$val0", "$val1"]}, + doc={"val0": FLOAT_INFINITY, "val1": -1}, + expected=float("-inf"), + msg="Should handle inf times negative", + ), + ExpressionTestCase( + "neg_inf_times_negative", + expression={"$multiply": ["$val0", "$val1"]}, + doc={"val0": FLOAT_NEGATIVE_INFINITY, "val1": -1}, + expected=float("inf"), + msg="Should handle neg inf times negative", + ), + ExpressionTestCase( + "nan_multiply_two", + expression={"$multiply": ["$val0", "$val1"]}, + doc={"val0": FLOAT_NAN, "val1": 2}, + expected=pytest.approx(math.nan, nan_ok=True), + msg="Should return NaN for nan multiply two", + ), + ExpressionTestCase( + "inf_times_zero", + expression={"$multiply": ["$val0", "$val1"]}, + doc={"val0": FLOAT_INFINITY, "val1": 0}, + expected=pytest.approx(math.nan, nan_ok=True), + msg="Should handle inf times zero", + ), + ExpressionTestCase( + "neg_inf_times_zero", + expression={"$multiply": ["$val0", "$val1"]}, + doc={"val0": FLOAT_NEGATIVE_INFINITY, "val1": 0}, + expected=pytest.approx(math.nan, nan_ok=True), + msg="Should handle neg inf times zero", + ), + ExpressionTestCase( + "nan_times_nan", + expression={"$multiply": ["$val0", "$val1"]}, + doc={"val0": FLOAT_NAN, "val1": FLOAT_NAN}, + expected=pytest.approx(math.nan, nan_ok=True), + msg="Should return NaN for nan times nan", + ), + ExpressionTestCase( + "decimal_nan_times_nan", + expression={"$multiply": ["$val0", "$val1"]}, + doc={"val0": DECIMAL128_NAN, "val1": DECIMAL128_NAN}, + expected=DECIMAL128_NAN, + msg="Should return NaN for decimal nan times nan", + ), + ExpressionTestCase( + "nan_times_inf", + expression={"$multiply": ["$val0", "$val1"]}, + doc={"val0": FLOAT_NAN, "val1": FLOAT_INFINITY}, + expected=pytest.approx(math.nan, nan_ok=True), + msg="Should return NaN for nan times inf", + ), + ExpressionTestCase( + "decimal_nan", + expression={"$multiply": ["$val0", "$val1"]}, + doc={"val0": DECIMAL128_NAN, "val1": 2}, + expected=DECIMAL128_NAN, + msg="Should return NaN for decimal nan", + ), + ExpressionTestCase( + "decimal_inf_times_zero", + expression={"$multiply": ["$val0", "$val1"]}, + doc={"val0": DECIMAL128_INFINITY, "val1": 0}, + expected=DECIMAL128_NAN, + msg="Should handle decimal inf times zero", + ), + ExpressionTestCase( + "infinity_mixed", + expression={"$multiply": ["$val0", 2]}, + doc={"val0": FLOAT_INFINITY}, + expected=float("inf"), + msg="Should handle infinity", + ), + ExpressionTestCase( + "negative_infinity_mixed", + expression={"$multiply": ["$val0", 2]}, + doc={"val0": FLOAT_NEGATIVE_INFINITY}, + expected=float("-inf"), + msg="Should handle negative infinity", + ), + ExpressionTestCase( + "decimal_infinity_mixed", + expression={"$multiply": ["$val0", 2]}, + doc={"val0": DECIMAL128_INFINITY}, + expected=Decimal128("Infinity"), + msg="Should handle decimal infinity", + ), + ExpressionTestCase( + "decimal_negative_infinity_mixed", + expression={"$multiply": ["$val0", 2]}, + doc={"val0": DECIMAL128_NEGATIVE_INFINITY}, + expected=Decimal128("-Infinity"), + msg="Should handle decimal negative infinity", + ), + ExpressionTestCase( + "inf_times_inf_mixed", + expression={"$multiply": ["$val0", FLOAT_INFINITY]}, + doc={"val0": FLOAT_INFINITY}, + expected=float("inf"), + msg="Should handle inf times inf", + ), + ExpressionTestCase( + "neg_inf_times_neg_inf_mixed", + expression={"$multiply": ["$val0", FLOAT_NEGATIVE_INFINITY]}, + doc={"val0": FLOAT_NEGATIVE_INFINITY}, + expected=float("inf"), + msg="Should handle neg inf times neg inf", + ), + ExpressionTestCase( + "inf_times_negative_mixed", + expression={"$multiply": ["$val0", -1]}, + doc={"val0": FLOAT_INFINITY}, + expected=float("-inf"), + msg="Should handle inf times negative", + ), + ExpressionTestCase( + "neg_inf_times_negative_mixed", + expression={"$multiply": ["$val0", -1]}, + doc={"val0": FLOAT_NEGATIVE_INFINITY}, + expected=float("inf"), + msg="Should handle neg inf times negative", + ), + ExpressionTestCase( + "nan_multiply_two_mixed", + expression={"$multiply": ["$val0", 2]}, + doc={"val0": FLOAT_NAN}, + expected=pytest.approx(math.nan, nan_ok=True), + msg="Should return NaN for nan multiply two", + ), + ExpressionTestCase( + "inf_times_zero_mixed", + expression={"$multiply": ["$val0", 0]}, + doc={"val0": FLOAT_INFINITY}, + expected=pytest.approx(math.nan, nan_ok=True), + msg="Should handle inf times zero", + ), + ExpressionTestCase( + "neg_inf_times_zero_mixed", + expression={"$multiply": ["$val0", 0]}, + doc={"val0": FLOAT_NEGATIVE_INFINITY}, + expected=pytest.approx(math.nan, nan_ok=True), + msg="Should handle neg inf times zero", + ), + ExpressionTestCase( + "nan_times_nan_mixed", + expression={"$multiply": ["$val0", FLOAT_NAN]}, + doc={"val0": FLOAT_NAN}, + expected=pytest.approx(math.nan, nan_ok=True), + msg="Should return NaN for nan times nan", + ), + ExpressionTestCase( + "decimal_nan_times_nan_mixed", + expression={"$multiply": ["$val0", DECIMAL128_NAN]}, + doc={"val0": DECIMAL128_NAN}, + expected=DECIMAL128_NAN, + msg="Should return NaN for decimal nan times nan", + ), + ExpressionTestCase( + "nan_times_inf_mixed", + expression={"$multiply": ["$val0", FLOAT_INFINITY]}, + doc={"val0": FLOAT_NAN}, + expected=pytest.approx(math.nan, nan_ok=True), + msg="Should return NaN for nan times inf", + ), + ExpressionTestCase( + "decimal_nan_mixed", + expression={"$multiply": ["$val0", 2]}, + doc={"val0": DECIMAL128_NAN}, + expected=DECIMAL128_NAN, + msg="Should return NaN for decimal nan", + ), + ExpressionTestCase( + "decimal_inf_times_zero_mixed", + expression={"$multiply": ["$val0", 0]}, + doc={"val0": DECIMAL128_INFINITY}, + expected=DECIMAL128_NAN, + msg="Should handle decimal inf times zero", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(MULTIPLY_LITERAL_TESTS)) +def test_multiply_literal(collection, test): + """Test $multiply from literals""" + result = execute_expression(collection, test.expression) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +@pytest.mark.parametrize("test", pytest_params(MULTIPLY_FIELD_REF_TESTS)) +def test_multiply_field_ref(collection, test): + """Test $multiply from documents, using all-field-reference and mixed + literal/field-reference operand forms.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/multiply/test_multiply_null_missing_sign_zero.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/multiply/test_multiply_null_missing_sign_zero.py new file mode 100644 index 000000000..3d940de34 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/multiply/test_multiply_null_missing_sign_zero.py @@ -0,0 +1,333 @@ +""" +Null, missing, sign, and zero tests for $multiply expression. + +Covers null/missing short-circuiting in any operand position, sign +combinations (positive/negative), zero, and negative-zero handling for +both double and Decimal128. +""" + +import pytest +from bson import Decimal128 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_NEGATIVE_ZERO, + MISSING, +) + +MULTIPLY_LITERAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "single_null", + expression={"$multiply": [None]}, + expected=None, + msg="Should return null for single null", + ), + ExpressionTestCase( + "null_operand", + expression={"$multiply": [2, None]}, + expected=None, + msg="Should return null for null operand", + ), + ExpressionTestCase( + "missing_field", + expression={"$multiply": [2, MISSING]}, + expected=None, + msg="Should return null for missing field", + ), + ExpressionTestCase( + "null_with_multiple", + expression={"$multiply": [2, 3, None]}, + expected=None, + msg="Should return null for null with multiple", + ), + ExpressionTestCase( + "null_in_middle", + expression={"$multiply": [2, 3, 4, None, 5]}, + expected=None, + msg="Should return null for null in middle", + ), + ExpressionTestCase( + "all_missing", + expression={"$multiply": [MISSING, MISSING]}, + expected=None, + msg="Should return null for all missing", + ), + ExpressionTestCase( + "null_and_string", + expression={"$multiply": [None, "string"]}, + expected=None, + msg="Should return null for null and string", + ), + ExpressionTestCase( + "missing_and_boolean", + expression={"$multiply": [MISSING, True]}, + expected=None, + msg="Should return null for missing and boolean", + ), + ExpressionTestCase( + "two_nulls", + expression={"$multiply": [None, None]}, + expected=None, + msg="Should return null for two nulls", + ), + ExpressionTestCase( + "null_with_invalid", + expression={"$multiply": [2, None, "string"]}, + expected=None, + msg="Should return null for null with invalid", + ), + ExpressionTestCase( + "negative_positive", + expression={"$multiply": [-5, 3]}, + expected=-15, + msg="Should handle negative positive", + ), + ExpressionTestCase( + "both_negative", + expression={"$multiply": [-10, -20]}, + expected=200, + msg="Should handle both negative", + ), + ExpressionTestCase( + "zero_multiply", + expression={"$multiply": [0, 5]}, + expected=0, + msg="Should handle zero multiply", + ), + ExpressionTestCase( + "zero_negative_zero", + expression={"$multiply": [0, -0.0]}, + expected=-0.0, + msg="Should handle zero negative zero", + ), + ExpressionTestCase( + "single_negative_zero", + expression={"$multiply": [-0.0]}, + expected=-0.0, + msg="Should handle single negative zero", + ), + ExpressionTestCase( + "zero_in_middle", + expression={"$multiply": [5, 0, 10]}, + expected=0, + msg="Should handle zero in middle", + ), + ExpressionTestCase( + "decimal_negative_zero_times_positive", + expression={"$multiply": [DECIMAL128_NEGATIVE_ZERO, 5]}, + expected=DECIMAL128_NEGATIVE_ZERO, + msg="Should handle decimal negative zero times positive", + ), + ExpressionTestCase( + "decimal_negative_zero_times_negative", + expression={"$multiply": [DECIMAL128_NEGATIVE_ZERO, -5]}, + expected=Decimal128("0"), + msg="Should handle decimal negative zero times negative", + ), +] + + +MULTIPLY_FIELD_REF_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "single_null", + expression={"$multiply": ["$val0"]}, + doc={"val0": None}, + expected=None, + msg="Should return null for single null", + ), + ExpressionTestCase( + "null_operand", + expression={"$multiply": ["$val0", "$val1"]}, + doc={"val0": 2, "val1": None}, + expected=None, + msg="Should return null for null operand", + ), + ExpressionTestCase( + "null_with_multiple", + expression={"$multiply": ["$val0", "$val1", "$val2"]}, + doc={"val0": 2, "val1": 3, "val2": None}, + expected=None, + msg="Should return null for null with multiple", + ), + ExpressionTestCase( + "null_in_middle", + expression={"$multiply": ["$val0", "$val1", "$val2", "$val3", "$val4"]}, + doc={"val0": 2, "val1": 3, "val2": 4, "val3": None, "val4": 5}, + expected=None, + msg="Should return null for null in middle", + ), + ExpressionTestCase( + "null_and_string", + expression={"$multiply": ["$val0", "$val1"]}, + doc={"val0": None, "val1": "string"}, + expected=None, + msg="Should return null for null and string", + ), + ExpressionTestCase( + "two_nulls", + expression={"$multiply": ["$val0", "$val1"]}, + doc={"val0": None, "val1": None}, + expected=None, + msg="Should return null for two nulls", + ), + ExpressionTestCase( + "null_with_invalid", + expression={"$multiply": ["$val0", "$val1", "$val2"]}, + doc={"val0": 2, "val1": None, "val2": "string"}, + expected=None, + msg="Should return null for null with invalid", + ), + ExpressionTestCase( + "negative_positive", + expression={"$multiply": ["$val0", "$val1"]}, + doc={"val0": -5, "val1": 3}, + expected=-15, + msg="Should handle negative positive", + ), + ExpressionTestCase( + "both_negative", + expression={"$multiply": ["$val0", "$val1"]}, + doc={"val0": -10, "val1": -20}, + expected=200, + msg="Should handle both negative", + ), + ExpressionTestCase( + "zero_multiply", + expression={"$multiply": ["$val0", "$val1"]}, + doc={"val0": 0, "val1": 5}, + expected=0, + msg="Should handle zero multiply", + ), + ExpressionTestCase( + "zero_negative_zero", + expression={"$multiply": ["$val0", "$val1"]}, + doc={"val0": 0, "val1": -0.0}, + expected=-0.0, + msg="Should handle zero negative zero", + ), + ExpressionTestCase( + "single_negative_zero", + expression={"$multiply": ["$val0"]}, + doc={"val0": -0.0}, + expected=-0.0, + msg="Should handle single negative zero", + ), + ExpressionTestCase( + "zero_in_middle", + expression={"$multiply": ["$val0", "$val1", "$val2"]}, + doc={"val0": 5, "val1": 0, "val2": 10}, + expected=0, + msg="Should handle zero in middle", + ), + ExpressionTestCase( + "null_operand_mixed", + expression={"$multiply": ["$val0", None]}, + doc={"val0": 2}, + expected=None, + msg="Should return null for null operand", + ), + ExpressionTestCase( + "missing_field", + expression={"$multiply": ["$val0", MISSING]}, + doc={"val0": 2}, + expected=None, + msg="Should return null for missing field", + ), + ExpressionTestCase( + "null_with_multiple_mixed", + expression={"$multiply": ["$val0", 3, None]}, + doc={"val0": 2}, + expected=None, + msg="Should return null for null with multiple", + ), + ExpressionTestCase( + "null_in_middle_mixed", + expression={"$multiply": ["$val0", 3, 4, None, 5]}, + doc={"val0": 2}, + expected=None, + msg="Should return null for null in middle", + ), + ExpressionTestCase( + "null_and_string_mixed", + expression={"$multiply": ["$val0", "string"]}, + doc={"val0": None}, + expected=None, + msg="Should return null for null and string", + ), + ExpressionTestCase( + "two_nulls_mixed", + expression={"$multiply": ["$val0", None]}, + doc={"val0": None}, + expected=None, + msg="Should return null for two nulls", + ), + ExpressionTestCase( + "null_with_invalid_mixed", + expression={"$multiply": ["$val0", None, "string"]}, + doc={"val0": 2}, + expected=None, + msg="Should return null for null with invalid", + ), + ExpressionTestCase( + "negative_positive_mixed", + expression={"$multiply": ["$val0", 3]}, + doc={"val0": -5}, + expected=-15, + msg="Should handle negative positive", + ), + ExpressionTestCase( + "both_negative_mixed", + expression={"$multiply": ["$val0", -20]}, + doc={"val0": -10}, + expected=200, + msg="Should handle both negative", + ), + ExpressionTestCase( + "zero_multiply_mixed", + expression={"$multiply": ["$val0", 5]}, + doc={"val0": 0}, + expected=0, + msg="Should handle zero multiply", + ), + ExpressionTestCase( + "zero_negative_zero_mixed", + expression={"$multiply": ["$val0", -0.0]}, + doc={"val0": 0}, + expected=-0.0, + msg="Should handle zero negative zero", + ), + ExpressionTestCase( + "zero_in_middle_mixed", + expression={"$multiply": ["$val0", 0, 10]}, + doc={"val0": 5}, + expected=0, + msg="Should handle zero in middle", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(MULTIPLY_LITERAL_TESTS)) +def test_multiply_literal(collection, test): + """Test $multiply from literals""" + result = execute_expression(collection, test.expression) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +@pytest.mark.parametrize("test", pytest_params(MULTIPLY_FIELD_REF_TESTS)) +def test_multiply_field_ref(collection, test): + """Test $multiply from documents, using all-field-reference and mixed + literal/field-reference operand forms.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/multiply/test_multiply_rounding_halves.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/multiply/test_multiply_rounding_halves.py new file mode 100644 index 000000000..3781f6d8a --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/multiply/test_multiply_rounding_halves.py @@ -0,0 +1,380 @@ +""" +Rounding-precision tests for $multiply expression near half values. + +Covers half, one-and-a-half, and two-and-a-half operands for double and +Decimal128, including values just below and just above a half boundary. +""" + +import pytest +from bson import Decimal128 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_HALF, + DECIMAL128_JUST_ABOVE_HALF, + DECIMAL128_JUST_BELOW_HALF, + DECIMAL128_NEGATIVE_HALF, + DECIMAL128_NEGATIVE_ONE_AND_HALF, + DECIMAL128_ONE_AND_HALF, + DECIMAL128_TWO_AND_HALF, + DOUBLE_HALF, + DOUBLE_JUST_ABOVE_HALF, + DOUBLE_JUST_BELOW_HALF, + DOUBLE_NEGATIVE_HALF, + DOUBLE_NEGATIVE_ONE_AND_HALF, + DOUBLE_ONE_AND_HALF, +) + +MULTIPLY_LITERAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "double_half_times_two", + expression={"$multiply": [DOUBLE_HALF, 2]}, + expected=1.0, + msg="Should handle double half times two", + ), + ExpressionTestCase( + "double_one_and_half_times_two", + expression={"$multiply": [DOUBLE_ONE_AND_HALF, 2]}, + expected=3.0, + msg="Should handle double one and half times two", + ), + ExpressionTestCase( + "double_negative_half_times_two", + expression={"$multiply": [DOUBLE_NEGATIVE_HALF, 2]}, + expected=-1.0, + msg="Should handle double negative half times two", + ), + ExpressionTestCase( + "double_negative_one_and_half_times_two", + expression={"$multiply": [DOUBLE_NEGATIVE_ONE_AND_HALF, 2]}, + expected=-3.0, + msg="Should handle double negative one and half times two", + ), + ExpressionTestCase( + "double_just_below_half_times_two", + expression={"$multiply": [DOUBLE_JUST_BELOW_HALF, 2]}, + expected=pytest.approx(0.9999999999999988), + msg="Should handle double just below half times two", + ), + ExpressionTestCase( + "double_just_above_half_times_two", + expression={"$multiply": [DOUBLE_JUST_ABOVE_HALF, 2]}, + expected=pytest.approx(1.000000002), + msg="Should handle double just above half times two", + ), + ExpressionTestCase( + "double_half_times_half", + expression={"$multiply": [DOUBLE_HALF, DOUBLE_HALF]}, + expected=0.25, + msg="Should handle double half times half", + ), + ExpressionTestCase( + "double_one_and_half_times_one_and_half", + expression={"$multiply": [DOUBLE_ONE_AND_HALF, DOUBLE_ONE_AND_HALF]}, + expected=2.25, + msg="Should handle double one and half times one and half", + ), + ExpressionTestCase( + "decimal_half_times_two", + expression={"$multiply": [DECIMAL128_HALF, 2]}, + expected=Decimal128("1.0"), + msg="Should return correct result for decimal half times two", + ), + ExpressionTestCase( + "decimal_one_and_half_times_two", + expression={"$multiply": [DECIMAL128_ONE_AND_HALF, 2]}, + expected=Decimal128("3.0"), + msg="Should return correct result for decimal one and half times two", + ), + ExpressionTestCase( + "decimal_two_and_half_times_two", + expression={"$multiply": [DECIMAL128_TWO_AND_HALF, 2]}, + expected=Decimal128("5.0"), + msg="Should return correct result for decimal two and half times two", + ), + ExpressionTestCase( + "decimal_negative_half_times_two", + expression={"$multiply": [DECIMAL128_NEGATIVE_HALF, 2]}, + expected=Decimal128("-1.0"), + msg="Should handle decimal negative half times two", + ), + ExpressionTestCase( + "decimal_negative_one_and_half_times_two", + expression={"$multiply": [DECIMAL128_NEGATIVE_ONE_AND_HALF, 2]}, + expected=Decimal128("-3.0"), + msg="Should handle decimal negative one and half times two", + ), + ExpressionTestCase( + "decimal_just_below_half_times_two", + expression={"$multiply": [DECIMAL128_JUST_BELOW_HALF, 2]}, + expected=Decimal128("0.9999999999999999999999999999999998"), + msg="Should return correct result for decimal just below half times two", + ), + ExpressionTestCase( + "decimal_just_above_half_times_two", + expression={"$multiply": [DECIMAL128_JUST_ABOVE_HALF, 2]}, + expected=Decimal128("1.000000000000000000000000000000000"), + msg="Should return correct result for decimal just above half times two", + ), + ExpressionTestCase( + "decimal_half_times_half", + expression={"$multiply": [DECIMAL128_HALF, DECIMAL128_HALF]}, + expected=Decimal128("0.25"), + msg="Should return correct result for decimal half times half", + ), +] + + +MULTIPLY_FIELD_REF_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "double_half_times_two", + expression={"$multiply": ["$val0", "$val1"]}, + doc={"val0": DOUBLE_HALF, "val1": 2}, + expected=1.0, + msg="Should handle double half times two", + ), + ExpressionTestCase( + "double_one_and_half_times_two", + expression={"$multiply": ["$val0", "$val1"]}, + doc={"val0": DOUBLE_ONE_AND_HALF, "val1": 2}, + expected=3.0, + msg="Should handle double one and half times two", + ), + ExpressionTestCase( + "double_negative_half_times_two", + expression={"$multiply": ["$val0", "$val1"]}, + doc={"val0": DOUBLE_NEGATIVE_HALF, "val1": 2}, + expected=-1.0, + msg="Should handle double negative half times two", + ), + ExpressionTestCase( + "double_negative_one_and_half_times_two", + expression={"$multiply": ["$val0", "$val1"]}, + doc={"val0": DOUBLE_NEGATIVE_ONE_AND_HALF, "val1": 2}, + expected=-3.0, + msg="Should handle double negative one and half times two", + ), + ExpressionTestCase( + "double_just_below_half_times_two", + expression={"$multiply": ["$val0", "$val1"]}, + doc={"val0": DOUBLE_JUST_BELOW_HALF, "val1": 2}, + expected=pytest.approx(0.9999999999999988), + msg="Should handle double just below half times two", + ), + ExpressionTestCase( + "double_just_above_half_times_two", + expression={"$multiply": ["$val0", "$val1"]}, + doc={"val0": DOUBLE_JUST_ABOVE_HALF, "val1": 2}, + expected=pytest.approx(1.000000002), + msg="Should handle double just above half times two", + ), + ExpressionTestCase( + "double_half_times_half", + expression={"$multiply": ["$val0", "$val1"]}, + doc={"val0": DOUBLE_HALF, "val1": DOUBLE_HALF}, + expected=0.25, + msg="Should handle double half times half", + ), + ExpressionTestCase( + "double_one_and_half_times_one_and_half", + expression={"$multiply": ["$val0", "$val1"]}, + doc={"val0": DOUBLE_ONE_AND_HALF, "val1": DOUBLE_ONE_AND_HALF}, + expected=2.25, + msg="Should handle double one and half times one and half", + ), + ExpressionTestCase( + "decimal_half_times_two", + expression={"$multiply": ["$val0", "$val1"]}, + doc={"val0": DECIMAL128_HALF, "val1": 2}, + expected=Decimal128("1.0"), + msg="Should return correct result for decimal half times two", + ), + ExpressionTestCase( + "decimal_one_and_half_times_two", + expression={"$multiply": ["$val0", "$val1"]}, + doc={"val0": DECIMAL128_ONE_AND_HALF, "val1": 2}, + expected=Decimal128("3.0"), + msg="Should return correct result for decimal one and half times two", + ), + ExpressionTestCase( + "decimal_two_and_half_times_two", + expression={"$multiply": ["$val0", "$val1"]}, + doc={"val0": DECIMAL128_TWO_AND_HALF, "val1": 2}, + expected=Decimal128("5.0"), + msg="Should return correct result for decimal two and half times two", + ), + ExpressionTestCase( + "decimal_negative_half_times_two", + expression={"$multiply": ["$val0", "$val1"]}, + doc={"val0": DECIMAL128_NEGATIVE_HALF, "val1": 2}, + expected=Decimal128("-1.0"), + msg="Should handle decimal negative half times two", + ), + ExpressionTestCase( + "decimal_negative_one_and_half_times_two", + expression={"$multiply": ["$val0", "$val1"]}, + doc={"val0": DECIMAL128_NEGATIVE_ONE_AND_HALF, "val1": 2}, + expected=Decimal128("-3.0"), + msg="Should handle decimal negative one and half times two", + ), + ExpressionTestCase( + "decimal_just_below_half_times_two", + expression={"$multiply": ["$val0", "$val1"]}, + doc={"val0": DECIMAL128_JUST_BELOW_HALF, "val1": 2}, + expected=Decimal128("0.9999999999999999999999999999999998"), + msg="Should return correct result for decimal just below half times two", + ), + ExpressionTestCase( + "decimal_just_above_half_times_two", + expression={"$multiply": ["$val0", "$val1"]}, + doc={"val0": DECIMAL128_JUST_ABOVE_HALF, "val1": 2}, + expected=Decimal128("1.000000000000000000000000000000000"), + msg="Should return correct result for decimal just above half times two", + ), + ExpressionTestCase( + "decimal_half_times_half", + expression={"$multiply": ["$val0", "$val1"]}, + doc={"val0": DECIMAL128_HALF, "val1": DECIMAL128_HALF}, + expected=Decimal128("0.25"), + msg="Should return correct result for decimal half times half", + ), + ExpressionTestCase( + "double_half_times_two_mixed", + expression={"$multiply": ["$val0", 2]}, + doc={"val0": DOUBLE_HALF}, + expected=1.0, + msg="Should handle double half times two", + ), + ExpressionTestCase( + "double_one_and_half_times_two_mixed", + expression={"$multiply": ["$val0", 2]}, + doc={"val0": DOUBLE_ONE_AND_HALF}, + expected=3.0, + msg="Should handle double one and half times two", + ), + ExpressionTestCase( + "double_negative_half_times_two_mixed", + expression={"$multiply": ["$val0", 2]}, + doc={"val0": DOUBLE_NEGATIVE_HALF}, + expected=-1.0, + msg="Should handle double negative half times two", + ), + ExpressionTestCase( + "double_negative_one_and_half_times_two_mixed", + expression={"$multiply": ["$val0", 2]}, + doc={"val0": DOUBLE_NEGATIVE_ONE_AND_HALF}, + expected=-3.0, + msg="Should handle double negative one and half times two", + ), + ExpressionTestCase( + "double_just_below_half_times_two_mixed", + expression={"$multiply": ["$val0", 2]}, + doc={"val0": DOUBLE_JUST_BELOW_HALF}, + expected=pytest.approx(0.9999999999999988), + msg="Should handle double just below half times two", + ), + ExpressionTestCase( + "double_just_above_half_times_two_mixed", + expression={"$multiply": ["$val0", 2]}, + doc={"val0": DOUBLE_JUST_ABOVE_HALF}, + expected=pytest.approx(1.000000002), + msg="Should handle double just above half times two", + ), + ExpressionTestCase( + "double_half_times_half_mixed", + expression={"$multiply": ["$val0", DOUBLE_HALF]}, + doc={"val0": DOUBLE_HALF}, + expected=0.25, + msg="Should handle double half times half", + ), + ExpressionTestCase( + "double_one_and_half_times_one_and_half_mixed", + expression={"$multiply": ["$val0", DOUBLE_ONE_AND_HALF]}, + doc={"val0": DOUBLE_ONE_AND_HALF}, + expected=2.25, + msg="Should handle double one and half times one and half", + ), + ExpressionTestCase( + "decimal_half_times_two_mixed", + expression={"$multiply": ["$val0", 2]}, + doc={"val0": DECIMAL128_HALF}, + expected=Decimal128("1.0"), + msg="Should return correct result for decimal half times two", + ), + ExpressionTestCase( + "decimal_one_and_half_times_two_mixed", + expression={"$multiply": ["$val0", 2]}, + doc={"val0": DECIMAL128_ONE_AND_HALF}, + expected=Decimal128("3.0"), + msg="Should return correct result for decimal one and half times two", + ), + ExpressionTestCase( + "decimal_two_and_half_times_two_mixed", + expression={"$multiply": ["$val0", 2]}, + doc={"val0": DECIMAL128_TWO_AND_HALF}, + expected=Decimal128("5.0"), + msg="Should return correct result for decimal two and half times two", + ), + ExpressionTestCase( + "decimal_negative_half_times_two_mixed", + expression={"$multiply": ["$val0", 2]}, + doc={"val0": DECIMAL128_NEGATIVE_HALF}, + expected=Decimal128("-1.0"), + msg="Should handle decimal negative half times two", + ), + ExpressionTestCase( + "decimal_negative_one_and_half_times_two_mixed", + expression={"$multiply": ["$val0", 2]}, + doc={"val0": DECIMAL128_NEGATIVE_ONE_AND_HALF}, + expected=Decimal128("-3.0"), + msg="Should handle decimal negative one and half times two", + ), + ExpressionTestCase( + "decimal_just_below_half_times_two_mixed", + expression={"$multiply": ["$val0", 2]}, + doc={"val0": DECIMAL128_JUST_BELOW_HALF}, + expected=Decimal128("0.9999999999999999999999999999999998"), + msg="Should return correct result for decimal just below half times two", + ), + ExpressionTestCase( + "decimal_just_above_half_times_two_mixed", + expression={"$multiply": ["$val0", 2]}, + doc={"val0": DECIMAL128_JUST_ABOVE_HALF}, + expected=Decimal128("1.000000000000000000000000000000000"), + msg="Should return correct result for decimal just above half times two", + ), + ExpressionTestCase( + "decimal_half_times_half_mixed", + expression={"$multiply": ["$val0", DECIMAL128_HALF]}, + doc={"val0": DECIMAL128_HALF}, + expected=Decimal128("0.25"), + msg="Should return correct result for decimal half times half", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(MULTIPLY_LITERAL_TESTS)) +def test_multiply_literal(collection, test): + """Test $multiply from literals""" + result = execute_expression(collection, test.expression) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +@pytest.mark.parametrize("test", pytest_params(MULTIPLY_FIELD_REF_TESTS)) +def test_multiply_field_ref(collection, test): + """Test $multiply from documents, using all-field-reference and mixed + literal/field-reference operand forms.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/multiply/test_multiply_type_matrix.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/multiply/test_multiply_type_matrix.py new file mode 100644 index 000000000..2cf12eb64 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/multiply/test_multiply_type_matrix.py @@ -0,0 +1,268 @@ +""" +Numeric type matrix tests for $multiply expression. + +Covers $multiply across every same-type and cross-type pairing of the four +numeric BSON types (int32, int64, double, decimal128), plus a three-type mix. +""" + +import pytest +from bson import ( + Decimal128, + Int64, +) + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +MULTIPLY_LITERAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "same_type_int32", + expression={"$multiply": [2, 3]}, + expected=6, + msg="Should handle same type int32", + ), + ExpressionTestCase( + "same_type_int64", + expression={"$multiply": [Int64(10), Int64(20)]}, + expected=Int64(200), + msg="Should handle same type int64", + ), + ExpressionTestCase( + "same_type_double", + expression={"$multiply": [1.5, 2.0]}, + expected=3.0, + msg="Should handle same type double", + ), + ExpressionTestCase( + "same_type_decimal", + expression={"$multiply": [Decimal128("10.5"), Decimal128("2")]}, + expected=Decimal128("21.0"), + msg="Should compute $multiply of decimal values", + ), + ExpressionTestCase( + "int32_int64", + expression={"$multiply": [2, Int64(20)]}, + expected=Int64(40), + msg="Should handle int32 int64", + ), + ExpressionTestCase( + "int32_double", + expression={"$multiply": [2, 2.5]}, + expected=5.0, + msg="Should handle int32 double", + ), + ExpressionTestCase( + "int32_decimal", + expression={"$multiply": [2, Decimal128("2.5")]}, + expected=Decimal128("5.0"), + msg="Should handle int32 decimal", + ), + ExpressionTestCase( + "int64_double", + expression={"$multiply": [Int64(10), 2.5]}, + expected=25.0, + msg="Should handle int64 double", + ), + ExpressionTestCase( + "int64_decimal", + expression={"$multiply": [Int64(10), Decimal128("2.5")]}, + expected=Decimal128("25.0"), + msg="Should handle int64 decimal", + ), + ExpressionTestCase( + "double_decimal", + expression={"$multiply": [1.5, Decimal128("2.0")]}, + expected=Decimal128("3.000000000000000"), + msg="Should handle double decimal", + ), + ExpressionTestCase( + "three_mixed_types", + expression={"$multiply": [Decimal128("2"), 1.5, Int64(3)]}, + expected=Decimal128("9.00000000000000"), + msg="Should return correct result for three mixed types", + ), +] + + +MULTIPLY_FIELD_REF_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "same_type_int32", + expression={"$multiply": ["$val0", "$val1"]}, + doc={"val0": 2, "val1": 3}, + expected=6, + msg="Should handle same type int32", + ), + ExpressionTestCase( + "same_type_int64", + expression={"$multiply": ["$val0", "$val1"]}, + doc={"val0": Int64(10), "val1": Int64(20)}, + expected=Int64(200), + msg="Should handle same type int64", + ), + ExpressionTestCase( + "same_type_double", + expression={"$multiply": ["$val0", "$val1"]}, + doc={"val0": 1.5, "val1": 2.0}, + expected=3.0, + msg="Should handle same type double", + ), + ExpressionTestCase( + "same_type_decimal", + expression={"$multiply": ["$val0", "$val1"]}, + doc={"val0": Decimal128("10.5"), "val1": Decimal128("2")}, + expected=Decimal128("21.0"), + msg="Should compute $multiply of decimal values", + ), + ExpressionTestCase( + "int32_int64", + expression={"$multiply": ["$val0", "$val1"]}, + doc={"val0": 2, "val1": Int64(20)}, + expected=Int64(40), + msg="Should handle int32 int64", + ), + ExpressionTestCase( + "int32_double", + expression={"$multiply": ["$val0", "$val1"]}, + doc={"val0": 2, "val1": 2.5}, + expected=5.0, + msg="Should handle int32 double", + ), + ExpressionTestCase( + "int32_decimal", + expression={"$multiply": ["$val0", "$val1"]}, + doc={"val0": 2, "val1": Decimal128("2.5")}, + expected=Decimal128("5.0"), + msg="Should handle int32 decimal", + ), + ExpressionTestCase( + "int64_double", + expression={"$multiply": ["$val0", "$val1"]}, + doc={"val0": Int64(10), "val1": 2.5}, + expected=25.0, + msg="Should handle int64 double", + ), + ExpressionTestCase( + "int64_decimal", + expression={"$multiply": ["$val0", "$val1"]}, + doc={"val0": Int64(10), "val1": Decimal128("2.5")}, + expected=Decimal128("25.0"), + msg="Should handle int64 decimal", + ), + ExpressionTestCase( + "double_decimal", + expression={"$multiply": ["$val0", "$val1"]}, + doc={"val0": 1.5, "val1": Decimal128("2.0")}, + expected=Decimal128("3.000000000000000"), + msg="Should handle double decimal", + ), + ExpressionTestCase( + "three_mixed_types", + expression={"$multiply": ["$val0", "$val1", "$val2"]}, + doc={"val0": Decimal128("2"), "val1": 1.5, "val2": Int64(3)}, + expected=Decimal128("9.00000000000000"), + msg="Should return correct result for three mixed types", + ), + ExpressionTestCase( + "same_type_int32_mixed", + expression={"$multiply": ["$val0", 3]}, + doc={"val0": 2}, + expected=6, + msg="Should handle same type int32", + ), + ExpressionTestCase( + "same_type_int64_mixed", + expression={"$multiply": ["$val0", Int64(20)]}, + doc={"val0": Int64(10)}, + expected=Int64(200), + msg="Should handle same type int64", + ), + ExpressionTestCase( + "same_type_double_mixed", + expression={"$multiply": ["$val0", 2.0]}, + doc={"val0": 1.5}, + expected=3.0, + msg="Should handle same type double", + ), + ExpressionTestCase( + "same_type_decimal_mixed", + expression={"$multiply": ["$val0", Decimal128("2")]}, + doc={"val0": Decimal128("10.5")}, + expected=Decimal128("21.0"), + msg="Should compute $multiply of decimal values", + ), + ExpressionTestCase( + "int32_int64_mixed", + expression={"$multiply": ["$val0", Int64(20)]}, + doc={"val0": 2}, + expected=Int64(40), + msg="Should handle int32 int64", + ), + ExpressionTestCase( + "int32_double_mixed", + expression={"$multiply": ["$val0", 2.5]}, + doc={"val0": 2}, + expected=5.0, + msg="Should handle int32 double", + ), + ExpressionTestCase( + "int32_decimal_mixed", + expression={"$multiply": ["$val0", Decimal128("2.5")]}, + doc={"val0": 2}, + expected=Decimal128("5.0"), + msg="Should handle int32 decimal", + ), + ExpressionTestCase( + "int64_double_mixed", + expression={"$multiply": ["$val0", 2.5]}, + doc={"val0": Int64(10)}, + expected=25.0, + msg="Should handle int64 double", + ), + ExpressionTestCase( + "int64_decimal_mixed", + expression={"$multiply": ["$val0", Decimal128("2.5")]}, + doc={"val0": Int64(10)}, + expected=Decimal128("25.0"), + msg="Should handle int64 decimal", + ), + ExpressionTestCase( + "double_decimal_mixed", + expression={"$multiply": ["$val0", Decimal128("2.0")]}, + doc={"val0": 1.5}, + expected=Decimal128("3.000000000000000"), + msg="Should handle double decimal", + ), + ExpressionTestCase( + "three_mixed_types_mixed", + expression={"$multiply": ["$val0", 1.5, Int64(3)]}, + doc={"val0": Decimal128("2")}, + expected=Decimal128("9.00000000000000"), + msg="Should return correct result for three mixed types", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(MULTIPLY_LITERAL_TESTS)) +def test_multiply_literal(collection, test): + """Test $multiply from literals""" + result = execute_expression(collection, test.expression) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +@pytest.mark.parametrize("test", pytest_params(MULTIPLY_FIELD_REF_TESTS)) +def test_multiply_field_ref(collection, test): + """Test $multiply from documents, using all-field-reference and mixed + literal/field-reference operand forms.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/multiply/test_multiply_variadic_arity.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/multiply/test_multiply_variadic_arity.py new file mode 100644 index 000000000..fd6f95487 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/multiply/test_multiply_variadic_arity.py @@ -0,0 +1,404 @@ +""" +Variadic arity tests for $multiply expression. + +Covers arity 0 (empty array to multiplicative identity), 1, 2, 3, 5, 10, 15, +and 20 operands, plus the non-array single-operand shorthand and the +self-nesting equivalence case. +""" + +import pytest +from bson import ( + Decimal128, + Int64, +) + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +MULTIPLY_LITERAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "multiple_int32", + expression={"$multiply": [2, 3, 4]}, + expected=24, + msg="Should handle multiple int32", + ), + ExpressionTestCase( + "multiple_int64", + expression={"$multiply": [Int64(2), Int64(3), Int64(4)]}, + expected=Int64(24), + msg="Should handle multiple int64", + ), + ExpressionTestCase( + "multiple_double", + expression={"$multiply": [1.5, 2.0, 3.0]}, + expected=pytest.approx(9.0), + msg="Should handle multiple double", + ), + ExpressionTestCase( + "five_operands", + expression={"$multiply": [1, 2, 3, 4, 5]}, + expected=120, + msg="Should return correct result for five operands", + ), + ExpressionTestCase( + "ten_operands", + expression={"$multiply": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]}, + expected=3628800, + msg="Should return correct result for ten operands", + ), + ExpressionTestCase( + "multiple_decimal", + expression={"$multiply": [Decimal128("2"), Decimal128("3"), Decimal128("4")]}, + expected=Decimal128("24"), + msg="Should return correct result for multiple decimal", + ), + ExpressionTestCase( + "empty", + expression={"$multiply": []}, + expected=1, + msg="Should return correct result for empty", + ), + ExpressionTestCase( + "single_int32", + expression={"$multiply": [5]}, + expected=5, + msg="Should handle single int32", + ), + ExpressionTestCase( + "non_array_single_operand", + expression={"$multiply": 5}, + expected=5, + msg="Should handle non-array single operand shorthand", + ), + ExpressionTestCase( + "single_int64", + expression={"$multiply": [Int64(10)]}, + expected=Int64(10), + msg="Should handle single int64", + ), + ExpressionTestCase( + "single_double", + expression={"$multiply": [2.5]}, + expected=2.5, + msg="Should handle single double", + ), + ExpressionTestCase( + "single_decimal", + expression={"$multiply": [Decimal128("5")]}, + expected=Decimal128("5"), + msg="Should return correct result for single decimal", + ), + ExpressionTestCase( + "fifteen_operands", + expression={"$multiply": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]}, + expected=Int64(1307674368000), + msg="Should return correct result for fifteen operands", + ), + ExpressionTestCase( + "twenty_operands", + expression={"$multiply": [2] * 20}, + expected=1048576, + msg="Should return correct result for twenty operands", + ), + ExpressionTestCase( + "self_nesting", + expression={"$multiply": [{"$multiply": [2, 3]}, 4]}, + expected=24, + msg="Should handle $multiply nested inside $multiply", + ), +] + + +MULTIPLY_FIELD_REF_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "multiple_int32", + expression={"$multiply": ["$val0", "$val1", "$val2"]}, + doc={"val0": 2, "val1": 3, "val2": 4}, + expected=24, + msg="Should handle multiple int32", + ), + ExpressionTestCase( + "multiple_int64", + expression={"$multiply": ["$val0", "$val1", "$val2"]}, + doc={"val0": Int64(2), "val1": Int64(3), "val2": Int64(4)}, + expected=Int64(24), + msg="Should handle multiple int64", + ), + ExpressionTestCase( + "multiple_double", + expression={"$multiply": ["$val0", "$val1", "$val2"]}, + doc={"val0": 1.5, "val1": 2.0, "val2": 3.0}, + expected=pytest.approx(9.0), + msg="Should handle multiple double", + ), + ExpressionTestCase( + "five_operands", + expression={"$multiply": ["$val0", "$val1", "$val2", "$val3", "$val4"]}, + doc={"val0": 1, "val1": 2, "val2": 3, "val3": 4, "val4": 5}, + expected=120, + msg="Should return correct result for five operands", + ), + ExpressionTestCase( + "ten_operands", + expression={ + "$multiply": [ + "$val0", + "$val1", + "$val2", + "$val3", + "$val4", + "$val5", + "$val6", + "$val7", + "$val8", + "$val9", + ] + }, + doc={ + "val0": 1, + "val1": 2, + "val2": 3, + "val3": 4, + "val4": 5, + "val5": 6, + "val6": 7, + "val7": 8, + "val8": 9, + "val9": 10, + }, + expected=3628800, + msg="Should return correct result for ten operands", + ), + ExpressionTestCase( + "multiple_decimal", + expression={"$multiply": ["$val0", "$val1", "$val2"]}, + doc={"val0": Decimal128("2"), "val1": Decimal128("3"), "val2": Decimal128("4")}, + expected=Decimal128("24"), + msg="Should return correct result for multiple decimal", + ), + ExpressionTestCase( + "empty", + expression={"$multiply": []}, + doc={}, + expected=1, + msg="Should return correct result for empty", + ), + ExpressionTestCase( + "single_int32", + expression={"$multiply": ["$val0"]}, + doc={"val0": 5}, + expected=5, + msg="Should handle single int32", + ), + ExpressionTestCase( + "single_int64", + expression={"$multiply": ["$val0"]}, + doc={"val0": Int64(10)}, + expected=Int64(10), + msg="Should handle single int64", + ), + ExpressionTestCase( + "single_double", + expression={"$multiply": ["$val0"]}, + doc={"val0": 2.5}, + expected=2.5, + msg="Should handle single double", + ), + ExpressionTestCase( + "single_decimal", + expression={"$multiply": ["$val0"]}, + doc={"val0": Decimal128("5")}, + expected=Decimal128("5"), + msg="Should return correct result for single decimal", + ), + ExpressionTestCase( + "fifteen_operands", + expression={ + "$multiply": [ + "$val0", + "$val1", + "$val2", + "$val3", + "$val4", + "$val5", + "$val6", + "$val7", + "$val8", + "$val9", + "$val10", + "$val11", + "$val12", + "$val13", + "$val14", + ] + }, + doc={ + "val0": 1, + "val1": 2, + "val2": 3, + "val3": 4, + "val4": 5, + "val5": 6, + "val6": 7, + "val7": 8, + "val8": 9, + "val9": 10, + "val10": 11, + "val11": 12, + "val12": 13, + "val13": 14, + "val14": 15, + }, + expected=Int64(1307674368000), + msg="Should return correct result for fifteen operands", + ), + ExpressionTestCase( + "twenty_operands", + expression={ + "$multiply": [ + "$val0", + "$val1", + "$val2", + "$val3", + "$val4", + "$val5", + "$val6", + "$val7", + "$val8", + "$val9", + "$val10", + "$val11", + "$val12", + "$val13", + "$val14", + "$val15", + "$val16", + "$val17", + "$val18", + "$val19", + ] + }, + doc={ + "val0": 2, + "val1": 2, + "val2": 2, + "val3": 2, + "val4": 2, + "val5": 2, + "val6": 2, + "val7": 2, + "val8": 2, + "val9": 2, + "val10": 2, + "val11": 2, + "val12": 2, + "val13": 2, + "val14": 2, + "val15": 2, + "val16": 2, + "val17": 2, + "val18": 2, + "val19": 2, + }, + expected=1048576, + msg="Should return correct result for twenty operands", + ), + ExpressionTestCase( + "self_nesting", + expression={"$multiply": [{"$multiply": ["$val0", "$val1"]}, "$val2"]}, + doc={"val0": 2, "val1": 3, "val2": 4}, + expected=24, + msg="Should handle $multiply nested inside $multiply", + ), + ExpressionTestCase( + "multiple_int32_mixed", + expression={"$multiply": ["$val0", 3, 4]}, + doc={"val0": 2}, + expected=24, + msg="Should handle multiple int32", + ), + ExpressionTestCase( + "multiple_int64_mixed", + expression={"$multiply": ["$val0", Int64(3), Int64(4)]}, + doc={"val0": Int64(2)}, + expected=Int64(24), + msg="Should handle multiple int64", + ), + ExpressionTestCase( + "multiple_double_mixed", + expression={"$multiply": ["$val0", 2.0, 3.0]}, + doc={"val0": 1.5}, + expected=pytest.approx(9.0), + msg="Should handle multiple double", + ), + ExpressionTestCase( + "five_operands_mixed", + expression={"$multiply": ["$val0", 2, 3, 4, 5]}, + doc={"val0": 1}, + expected=120, + msg="Should return correct result for five operands", + ), + ExpressionTestCase( + "ten_operands_mixed", + expression={"$multiply": ["$val0", 2, 3, 4, 5, 6, 7, 8, 9, 10]}, + doc={"val0": 1}, + expected=3628800, + msg="Should return correct result for ten operands", + ), + ExpressionTestCase( + "multiple_decimal_mixed", + expression={"$multiply": ["$val0", Decimal128("3"), Decimal128("4")]}, + doc={"val0": Decimal128("2")}, + expected=Decimal128("24"), + msg="Should return correct result for multiple decimal", + ), + ExpressionTestCase( + "fifteen_operands_mixed", + expression={"$multiply": ["$val0", 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]}, + doc={"val0": 1}, + expected=Int64(1307674368000), + msg="Should return correct result for fifteen operands", + ), + ExpressionTestCase( + "twenty_operands_mixed", + expression={ + "$multiply": ["$val0", 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2] + }, + doc={"val0": 2}, + expected=1048576, + msg="Should return correct result for twenty operands", + ), + ExpressionTestCase( + "self_nesting_mixed", + expression={"$multiply": [{"$multiply": ["$val0", 3]}, 4]}, + doc={"val0": 2}, + expected=24, + msg="Should handle $multiply nested inside $multiply", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(MULTIPLY_LITERAL_TESTS)) +def test_multiply_literal(collection, test): + """Test $multiply from literals""" + result = execute_expression(collection, test.expression) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +@pytest.mark.parametrize("test", pytest_params(MULTIPLY_FIELD_REF_TESTS)) +def test_multiply_field_ref(collection, test): + """Test $multiply from documents, using all-field-reference and mixed + literal/field-reference operand forms.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/pow/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/pow/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/pow/test_pow_boundaries_precision.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/pow/test_pow_boundaries_precision.py new file mode 100644 index 000000000..fc0d20b89 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/pow/test_pow_boundaries_precision.py @@ -0,0 +1,293 @@ +""" +Boundary and precision tests for $pow expression. + +Covers INT32/INT64 max/min (and adjacent) values through the promotion +chain, double overflow/underflow near the double range limits (including +subnormals), and Decimal128 precision/boundary values, plus rounding +near half values for double and Decimal128. +""" + +import pytest +from bson import ( + Decimal128, + Int64, +) + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_HALF, + DECIMAL128_INFINITY, + DECIMAL128_JUST_ABOVE_HALF, + DECIMAL128_JUST_BELOW_HALF, + DECIMAL128_MAX, + DECIMAL128_MIN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_ONE_AND_HALF, + DOUBLE_HALF, + DOUBLE_JUST_ABOVE_HALF, + DOUBLE_JUST_BELOW_HALF, + DOUBLE_MIN_NEGATIVE_SUBNORMAL, + DOUBLE_MIN_SUBNORMAL, + DOUBLE_NEAR_MAX, + DOUBLE_NEAR_MIN, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_ONE_AND_HALF, + INT32_MAX, + INT32_MAX_MINUS_1, + INT32_MIN, + INT32_MIN_PLUS_1, + INT64_MAX, + INT64_MAX_MINUS_1, + INT64_MIN, + INT64_MIN_PLUS_1, +) + +POW_LITERAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "ten_to_ten", + expression={"$pow": [10, 10]}, + expected=Int64(10000000000), + msg="Should return correct result for ten to ten", + ), + ExpressionTestCase( + "two_to_twenty", + expression={"$pow": [2, 20]}, + expected=1048576, + msg="Should return correct result for two to twenty", + ), + ExpressionTestCase( + "two_to_thirty", + expression={"$pow": [2, 30]}, + expected=1073741824, + msg="Should return correct result for two to thirty", + ), + ExpressionTestCase( + "int32_max_base", + expression={"$pow": [INT32_MAX, 1]}, + expected=INT32_MAX, + msg="Should handle int32 max base", + ), + ExpressionTestCase( + "int32_max_minus_1_base", + expression={"$pow": [INT32_MAX_MINUS_1, 1]}, + expected=INT32_MAX_MINUS_1, + msg="Should handle int32 max minus 1 base", + ), + ExpressionTestCase( + "int32_max_squared", + expression={"$pow": [INT32_MAX, 2]}, + expected=pytest.approx(4.611686014132421e18), + msg="Should handle int32 max squared", + ), + ExpressionTestCase( + "int64_max_base", + expression={"$pow": [INT64_MAX, 1]}, + expected=INT64_MAX, + msg="Should handle int64 max base", + ), + ExpressionTestCase( + "int64_max_minus_1_base", + expression={"$pow": [INT64_MAX_MINUS_1, 1]}, + expected=INT64_MAX_MINUS_1, + msg="Should handle int64 max minus 1 base", + ), + ExpressionTestCase( + "int32_min_base_even", + expression={"$pow": [INT32_MIN, 2]}, + expected=pytest.approx(4.611686018427388e18), + msg="Should handle int32 min base even", + ), + ExpressionTestCase( + "int32_min_plus_1_base_even", + expression={"$pow": [INT32_MIN_PLUS_1, 2]}, + expected=pytest.approx(4.611686014132421e18), + msg="Should handle int32 min plus 1 base even", + ), + ExpressionTestCase( + "int64_min_base_even", + expression={"$pow": [INT64_MIN, 2]}, + expected=pytest.approx(8.507059173023462e37), + msg="Should handle int64 min base even", + ), + ExpressionTestCase( + "int64_min_plus_1_base_even", + expression={"$pow": [INT64_MIN_PLUS_1, 2]}, + expected=pytest.approx(8.507059173023462e37), + msg="Should handle int64 min plus 1 base even", + ), + ExpressionTestCase( + "near_overflow", + expression={"$pow": [10, 308]}, + expected=pytest.approx(1e308), + msg="Should handle near overflow", + ), + ExpressionTestCase( + "overflow", + expression={"$pow": [10, 309]}, + expected=float("inf"), + msg="Should handle overflow", + ), + ExpressionTestCase( + "overflow_two", + expression={"$pow": [2, 1024]}, + expected=float("inf"), + msg="Should handle overflow two", + ), + ExpressionTestCase( + "extreme_overflow", + expression={"$pow": [1000, 1000]}, + expected=float("inf"), + msg="Should handle extreme overflow", + ), + ExpressionTestCase( + "tiny_exponent", + expression={"$pow": [2, 0.0001]}, + expected=pytest.approx(1.0000693387462580), + msg="Should return correct result for tiny exponent", + ), + ExpressionTestCase( + "tiny_exponent_ten", + expression={"$pow": [10, 0.001]}, + expected=pytest.approx(1.0023052380778994), + msg="Should return correct result for tiny exponent ten", + ), + ExpressionTestCase( + "double_min_subnormal_base", + expression={"$pow": [DOUBLE_MIN_SUBNORMAL, 2]}, + expected=0.0, + msg="Should underflow to zero for min subnormal double base squared", + ), + ExpressionTestCase( + "double_min_negative_subnormal_base", + expression={"$pow": [DOUBLE_MIN_NEGATIVE_SUBNORMAL, 3]}, + expected=DOUBLE_NEGATIVE_ZERO, + msg="Should underflow to negative zero for min negative subnormal double cubed", + ), + ExpressionTestCase( + "double_near_max_base", + expression={"$pow": [DOUBLE_NEAR_MAX, 1]}, + expected=DOUBLE_NEAR_MAX, + msg="Should preserve near-max double value when raised to the first power", + ), + ExpressionTestCase( + "double_near_min_base", + expression={"$pow": [DOUBLE_NEAR_MIN, 1]}, + expected=DOUBLE_NEAR_MIN, + msg="Should preserve near-min double value when raised to the first power", + ), + ExpressionTestCase( + "decimal_max_base_to_one", + expression={"$pow": [DECIMAL128_MAX, 1]}, + expected=DECIMAL128_INFINITY, + msg=( + "$pow overflows Decimal128 max to infinity even at exponent 1 " + "(unlike $multiply, which preserves the value)" + ), + ), + ExpressionTestCase( + "decimal_min_base_to_one", + expression={"$pow": [DECIMAL128_MIN, 1]}, + expected=DECIMAL128_NEGATIVE_INFINITY, + msg="$pow overflows Decimal128 min to negative infinity even at exponent 1", + ), + ExpressionTestCase( + "decimal_max_base_squared", + expression={"$pow": [DECIMAL128_MAX, 2]}, + expected=DECIMAL128_INFINITY, + msg="Should overflow to decimal infinity for max decimal128 base squared", + ), + ExpressionTestCase( + "one_base_int64_max_exp", + expression={"$pow": [1, INT64_MAX]}, + expected=Int64(1), + msg="Should handle one base raised to INT64_MAX", + ), + ExpressionTestCase( + "zero_base_int64_max_exp", + expression={"$pow": [0, INT64_MAX]}, + expected=Int64(0), + msg="Should handle zero base raised to INT64_MAX", + ), + ExpressionTestCase( + "double_half_base_squared", + expression={"$pow": [DOUBLE_HALF, 2]}, + expected=0.25, + msg="Should handle double half base squared", + ), + ExpressionTestCase( + "double_one_and_half_base_squared", + expression={"$pow": [DOUBLE_ONE_AND_HALF, 2]}, + expected=2.25, + msg="Should handle double one and half base squared", + ), + ExpressionTestCase( + "double_just_below_half_squared", + expression={"$pow": [DOUBLE_JUST_BELOW_HALF, 2]}, + expected=pytest.approx(0.24999999999999942), + msg="Should handle double just below half squared", + ), + ExpressionTestCase( + "double_just_above_half_squared", + expression={"$pow": [DOUBLE_JUST_ABOVE_HALF, 2]}, + expected=pytest.approx(0.250000001000000001), + msg="Should handle double just above half squared", + ), + ExpressionTestCase( + "decimal_half_squared", + expression={"$pow": [DECIMAL128_HALF, 2]}, + expected=Decimal128("0.2500000000000000000000000000000000"), + msg="Should return correct result for decimal half squared", + ), + ExpressionTestCase( + "decimal_one_and_half_squared", + expression={"$pow": [DECIMAL128_ONE_AND_HALF, 2]}, + expected=Decimal128("2.250000000000000000000000000000000"), + msg="Should return correct result for decimal one and half squared", + ), + ExpressionTestCase( + "decimal_just_below_half_squared", + expression={"$pow": [DECIMAL128_JUST_BELOW_HALF, 2]}, + expected=Decimal128("0.2499999999999999999999999999999999"), + msg="Should return correct result for decimal just below half squared", + ), + ExpressionTestCase( + "decimal_just_above_half_squared", + expression={"$pow": [DECIMAL128_JUST_ABOVE_HALF, 2]}, + expected=Decimal128("0.2500000000000000000000000000000001"), + msg="Should return correct result for decimal just above half squared", + ), + ExpressionTestCase( + "decimal_precision", + expression={"$pow": [Decimal128("1.5"), Decimal128("2")]}, + expected=Decimal128("2.250000000000000000000000000000000"), + msg="Should preserve precision for decimal precision", + ), + ExpressionTestCase( + "decimal_large", + expression={"$pow": [Decimal128("10"), Decimal128("20")]}, + expected=Decimal128("100000000000000000000.0000000000000"), + msg="Should return correct result for decimal large", + ), + ExpressionTestCase( + "decimal_two_to_hundred", + expression={"$pow": [Decimal128("2"), 100]}, + expected=Decimal128("1267650600228229401496703205376.000"), + msg="Should preserve decimal128 precision for 2^100", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(POW_LITERAL_TESTS)) +def test_pow_literal(collection, test): + """Test $pow from literals""" + result = execute_expression(collection, test.expression) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/pow/test_pow_bson_type_validation.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/pow/test_pow_bson_type_validation.py new file mode 100644 index 000000000..78d82c546 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/pow/test_pow_bson_type_validation.py @@ -0,0 +1,84 @@ +""" +BSON type validation tests for $pow expression. + +Verifies that $pow rejects invalid BSON types for its base and exponent +input positions and accepts valid numeric types, via the shared +bson_type_validator harness. Base and exponent report distinct error codes. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.assertions import assertNotError +from documentdb_tests.framework.bson_type_validator import ( + BsonType, + BsonTypeTestCase, + generate_bson_acceptance_test_cases, + generate_bson_rejection_test_cases, +) +from documentdb_tests.framework.error_codes import ( + POW_NON_NUMERIC_BASE_ERROR, + POW_NON_NUMERIC_EXP_ERROR, +) + +# $pow accepts the four numeric types in either position. null propagates to a +# null result, so it is treated as accepted. Every other BSON type is rejected; +# the base and exponent positions report distinct error codes. +NUMERIC_AND_NULL = [ + BsonType.DOUBLE, + BsonType.INT, + BsonType.LONG, + BsonType.DECIMAL, + BsonType.NULL, +] + +POW_BSON_PARAMS = [ + BsonTypeTestCase( + id="base", + msg="$pow base should reject non-numeric types", + keyword="base", + valid_types=NUMERIC_AND_NULL, + default_error_code=POW_NON_NUMERIC_BASE_ERROR, + ), + BsonTypeTestCase( + id="exponent", + msg="$pow exponent should reject non-numeric types", + keyword="exponent", + valid_types=NUMERIC_AND_NULL, + default_error_code=POW_NON_NUMERIC_EXP_ERROR, + ), +] + +REJECTION_CASES = generate_bson_rejection_test_cases(POW_BSON_PARAMS) +ACCEPTANCE_CASES = generate_bson_acceptance_test_cases(POW_BSON_PARAMS) + + +def _build_pow_expr(spec, sample_value): + """Build a $pow expression with sample_value in the position under test.""" + doc = {"value": sample_value} + if spec.keyword == "base": + return {"$pow": ["$value", 2]}, doc + return {"$pow": [2, "$value"]}, doc + + +@pytest.mark.parametrize("bson_type,sample_value,spec", REJECTION_CASES) +def test_pow_bson_type_rejected(collection, bson_type, sample_value, spec): + """Verifies $pow rejects invalid BSON types per input position.""" + expression, doc = _build_pow_expr(spec, sample_value) + result = execute_expression_with_insert(collection, expression, doc) + assert_expression_result( + result, + error_code=spec.expected_code(bson_type), + msg=f"{spec.msg}: {bson_type.value}", + ) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", ACCEPTANCE_CASES) +def test_pow_bson_type_accepted(collection, bson_type, sample_value, spec): + """Verifies $pow accepts valid numeric BSON types (and null) per position.""" + expression, doc = _build_pow_expr(spec, sample_value) + result = execute_expression_with_insert(collection, expression, doc) + assertNotError(result, msg=f"$pow {spec.keyword} should accept {bson_type.value}") diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/pow/test_pow_core_arithmetic.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/pow/test_pow_core_arithmetic.py new file mode 100644 index 000000000..34de86ef2 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/pow/test_pow_core_arithmetic.py @@ -0,0 +1,249 @@ +""" +Core arithmetic tests for $pow expression. + +Covers $pow across every same-type and cross-type pairing of the four +numeric BSON types (int32, int64, double, decimal128). +""" + +import pytest +from bson import ( + Decimal128, + Int64, +) + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, +) +from documentdb_tests.framework.parametrize import pytest_params + +POW_LITERAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "same_type_int32", + expression={"$pow": [2, 3]}, + expected=8, + msg="Should handle same type int32", + ), + ExpressionTestCase( + "same_type_int64", + expression={"$pow": [Int64(2), Int64(10)]}, + expected=Int64(1024), + msg="Should handle same type int64", + ), + ExpressionTestCase( + "same_type_double", + expression={"$pow": [2.0, 3.0]}, + expected=8.0, + msg="Should handle same type double", + ), + ExpressionTestCase( + "same_type_decimal", + expression={"$pow": [Decimal128("2"), Decimal128("3")]}, + expected=Decimal128("8.000000000000000000000000000000000"), + msg="Should compute $pow of decimal values", + ), + ExpressionTestCase( + "int32_int64", + expression={"$pow": [2, Int64(10)]}, + expected=Int64(1024), + msg="Should handle int32 int64", + ), + ExpressionTestCase( + "int32_double", + expression={"$pow": [2, 3.0]}, + expected=8.0, + msg="Should handle int32 double", + ), + ExpressionTestCase( + "int32_decimal", + expression={"$pow": [2, Decimal128("3")]}, + expected=Decimal128("8.000000000000000000000000000000000"), + msg="Should handle int32 decimal", + ), + ExpressionTestCase( + "int64_double", + expression={"$pow": [Int64(2), 10.0]}, + expected=1024.0, + msg="Should handle int64 double", + ), + ExpressionTestCase( + "int64_decimal", + expression={"$pow": [Int64(2), Decimal128("10")]}, + expected=Decimal128("1024.000000000000000000000000000000"), + msg="Should handle int64 decimal", + ), + ExpressionTestCase( + "double_decimal", + expression={"$pow": [2.0, Decimal128("3")]}, + expected=Decimal128("8.000000000000000000000000000000000"), + msg="Should handle double decimal", + ), + ExpressionTestCase( + "power_zero", + expression={"$pow": [10, 0]}, + expected=1, + msg="Should handle power zero", + ), + ExpressionTestCase( + "power_one", + expression={"$pow": [10, 1]}, + expected=10, + msg="Should return correct result for power one", + ), + ExpressionTestCase( + "power_two", + expression={"$pow": [10, 2]}, + expected=100, + msg="Should return correct result for power two", + ), + ExpressionTestCase( + "two_to_ten", + expression={"$pow": [2, 10]}, + expected=1024, + msg="Should return correct result for two to ten", + ), + ExpressionTestCase( + "three_to_four", + expression={"$pow": [3, 4]}, + expected=81, + msg="Should return correct result for three to four", + ), + ExpressionTestCase( + "five_to_three", + expression={"$pow": [5, 3]}, + expected=125, + msg="Should return correct result for five to three", + ), + ExpressionTestCase( + "square_root", + expression={"$pow": [4, 0.5]}, + expected=2.0, + msg="Should return correct result for square root", + ), + ExpressionTestCase( + "cube_root", + expression={"$pow": [8, 1.0 / 3.0]}, + expected=pytest.approx(2.0), + msg="Should return correct result for cube root", + ), + ExpressionTestCase( + "fourth_root", + expression={"$pow": [16, 0.25]}, + expected=2.0, + msg="Should return correct result for fourth root", + ), + ExpressionTestCase( + "fractional_both", + expression={"$pow": [2.5, 2.5]}, + expected=pytest.approx(9.882117688026186), + msg="Should handle fractional both", + ), + ExpressionTestCase( + "negative_exponent", + expression={"$pow": [2, -1]}, + expected=0.5, + msg="Should handle negative exponent", + ), + ExpressionTestCase( + "negative_exponent_ten", + expression={"$pow": [10, -2]}, + expected=0.01, + msg="Should handle negative exponent ten", + ), + ExpressionTestCase( + "negative_exponent_five", + expression={"$pow": [5, -3]}, + expected=0.008, + msg="Should handle negative exponent five", + ), + ExpressionTestCase( + "negative_base_odd", + expression={"$pow": [-2, 3]}, + expected=-8, + msg="Should handle negative base odd", + ), + ExpressionTestCase( + "negative_base_even", + expression={"$pow": [-2, 4]}, + expected=16, + msg="Should handle negative base even", + ), + ExpressionTestCase( + "negative_one_even", + expression={"$pow": [-1, 100]}, + expected=1, + msg="Should handle negative one even", + ), + ExpressionTestCase( + "negative_one_odd", + expression={"$pow": [-1, 101]}, + expected=-1, + msg="Should handle negative one odd", + ), + ExpressionTestCase( + "zero_base", + expression={"$pow": [0, 5]}, + expected=0, + msg="Should handle zero base", + ), + ExpressionTestCase( + "zero_to_zero", + expression={"$pow": [0, 0]}, + expected=1, + msg="Should handle zero to zero", + ), + ExpressionTestCase( + "zero_double_to_zero", + expression={"$pow": [0.0, 0.0]}, + expected=1.0, + msg="Should handle zero double to zero", + ), + ExpressionTestCase( + "one_base", + expression={"$pow": [1, 1000]}, + expected=1, + msg="Should return correct result for one base", + ), + ExpressionTestCase( + "one_base_negative_exp", + expression={"$pow": [1, -1000]}, + expected=1, + msg="Should handle one base negative exp", + ), + ExpressionTestCase( + "negative_one_base_negative_one_exp", + expression={"$pow": [-1, -1]}, + expected=-1, + msg="Should handle negative base with negative integer exponent (reciprocal, odd)", + ), + ExpressionTestCase( + "negative_one_base_negative_two_exp", + expression={"$pow": [-1, -2]}, + expected=1, + msg="Should handle negative base with negative integer exponent (reciprocal, even)", + ), + ExpressionTestCase( + "zero_base_fractional_positive_exp", + expression={"$pow": [0, 0.5]}, + expected=0.0, + msg="Should handle zero base with fractional positive exponent", + ), + ExpressionTestCase( + "expression_operator_inputs", + expression={"$pow": [{"$add": [1, 1]}, {"$subtract": [4, 2]}]}, + expected=4, + msg="Should handle expression-operator inputs for base and exponent", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(POW_LITERAL_TESTS)) +def test_pow_literal(collection, test): + """Test $pow from literals""" + result = execute_expression(collection, test.expression) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/pow/test_pow_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/pow/test_pow_errors.py new file mode 100644 index 000000000..ee681c00c --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/pow/test_pow_errors.py @@ -0,0 +1,215 @@ +""" +Error tests for $pow expression. + +Covers non-numeric base/exponent rejection (empty array, empty object), +zero-base-with-negative-exponent rejection, and argument-count/ +argument-shape errors. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + EXPRESSION_TYPE_MISMATCH_ERROR, + POW_BASE_ZERO_EXP_NEGATIVE_ERROR, + POW_NON_NUMERIC_BASE_ERROR, + POW_NON_NUMERIC_EXP_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params + +POW_LITERAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "empty_array_base", + expression={"$pow": [[], 2]}, + error_code=POW_NON_NUMERIC_BASE_ERROR, + msg="Should reject empty array base", + ), + ExpressionTestCase( + "empty_object_base", + expression={"$pow": [{}, 2]}, + error_code=POW_NON_NUMERIC_BASE_ERROR, + msg="Should reject empty object base", + ), + ExpressionTestCase( + "empty_array_exponent", + expression={"$pow": [2, []]}, + error_code=POW_NON_NUMERIC_EXP_ERROR, + msg="Should reject empty array exponent", + ), + ExpressionTestCase( + "empty_object_exponent", + expression={"$pow": [2, {}]}, + error_code=POW_NON_NUMERIC_EXP_ERROR, + msg="Should reject empty object exponent", + ), + ExpressionTestCase( + "zero_negative_exp", + expression={"$pow": [0, -1]}, + error_code=POW_BASE_ZERO_EXP_NEGATIVE_ERROR, + msg="Should reject zero negative exp", + ), + ExpressionTestCase( + "zero_negative_exp_five", + expression={"$pow": [0, -5]}, + error_code=POW_BASE_ZERO_EXP_NEGATIVE_ERROR, + msg="Should reject zero negative exp five", + ), + ExpressionTestCase( + "zero_fractional_negative_exp", + expression={"$pow": [0, -0.5]}, + error_code=POW_BASE_ZERO_EXP_NEGATIVE_ERROR, + msg="Should reject zero fractional negative exp", + ), + ExpressionTestCase( + "zero_double_negative_exp", + expression={"$pow": [0.0, -2.5]}, + error_code=POW_BASE_ZERO_EXP_NEGATIVE_ERROR, + msg="Should reject zero double negative exp", + ), + ExpressionTestCase( + "arity_zero_args", + expression={"$pow": []}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="Should reject $pow with zero arguments", + ), + ExpressionTestCase( + "arity_one_arg", + expression={"$pow": [5]}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="Should reject $pow with a single argument", + ), + ExpressionTestCase( + "arity_three_args", + expression={"$pow": [2, 3, 4]}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="Should reject $pow with three arguments", + ), + ExpressionTestCase( + "arity_non_array", + expression={"$pow": 5}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="Should reject $pow with a non-array operand", + ), + ExpressionTestCase( + "arity_array_wrapped_single_arg", + expression={"$pow": [[2, 3]]}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="Should reject $pow with a single array-wrapped argument", + ), +] + + +POW_INSERT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "empty_array_base", + expression={"$pow": ["$base", "$power"]}, + doc={"base": [], "power": 2}, + error_code=POW_NON_NUMERIC_BASE_ERROR, + msg="Should reject empty array base", + ), + ExpressionTestCase( + "empty_object_base", + expression={"$pow": ["$base", "$power"]}, + doc={"base": {}, "power": 2}, + error_code=POW_NON_NUMERIC_BASE_ERROR, + msg="Should reject empty object base", + ), + ExpressionTestCase( + "empty_array_exponent", + expression={"$pow": ["$base", "$power"]}, + doc={"base": 2, "power": []}, + error_code=POW_NON_NUMERIC_EXP_ERROR, + msg="Should reject empty array exponent", + ), + ExpressionTestCase( + "empty_object_exponent", + expression={"$pow": ["$base", "$power"]}, + doc={"base": 2, "power": {}}, + error_code=POW_NON_NUMERIC_EXP_ERROR, + msg="Should reject empty object exponent", + ), + ExpressionTestCase( + "zero_negative_exp", + expression={"$pow": ["$base", "$power"]}, + doc={"base": 0, "power": -1}, + error_code=POW_BASE_ZERO_EXP_NEGATIVE_ERROR, + msg="Should reject zero negative exp", + ), + ExpressionTestCase( + "zero_negative_exp_five", + expression={"$pow": ["$base", "$power"]}, + doc={"base": 0, "power": -5}, + error_code=POW_BASE_ZERO_EXP_NEGATIVE_ERROR, + msg="Should reject zero negative exp five", + ), + ExpressionTestCase( + "zero_fractional_negative_exp", + expression={"$pow": ["$base", "$power"]}, + doc={"base": 0, "power": -0.5}, + error_code=POW_BASE_ZERO_EXP_NEGATIVE_ERROR, + msg="Should reject zero fractional negative exp", + ), + ExpressionTestCase( + "zero_double_negative_exp", + expression={"$pow": ["$base", "$power"]}, + doc={"base": 0.0, "power": -2.5}, + error_code=POW_BASE_ZERO_EXP_NEGATIVE_ERROR, + msg="Should reject zero double negative exp", + ), + ExpressionTestCase( + "composite_array_path_base", + expression={"$pow": ["$a.b", 2]}, + doc={"a": [{"b": 2}, {"b": 3}]}, + error_code=POW_NON_NUMERIC_BASE_ERROR, + msg="Should reject base resolved from a composite array-path field lookup", + ), + ExpressionTestCase( + "arity_array_wrapped_single_arg_field_refs", + expression={"$pow": [["$x", "$y"]]}, + doc={"x": 2, "y": 3}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="Should reject $pow with a single array-wrapped argument of field refs", + ), + ExpressionTestCase( + "object_base_with_field_ref_value", + expression={"$pow": [{"a": "$x"}, 2]}, + doc={"x": 2}, + error_code=POW_NON_NUMERIC_BASE_ERROR, + msg="Should reject object base containing a field-ref value", + ), + ExpressionTestCase( + "array_index_path_base", + expression={"$pow": ["$a.0.b", 2]}, + doc={"a": [{"b": 1}, {"b": 2}]}, + error_code=POW_NON_NUMERIC_BASE_ERROR, + msg=( + "Array-index-path field lookup ($a.0.b) resolves to an empty" + " array (not element 0), which $pow rejects as non-numeric base" + ), + ), +] + + +@pytest.mark.parametrize("test", pytest_params(POW_LITERAL_TESTS)) +def test_pow_literal(collection, test): + """Test $pow from literals""" + result = execute_expression(collection, test.expression) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +@pytest.mark.parametrize("test", pytest_params(POW_INSERT_TESTS)) +def test_pow_insert(collection, test): + """Test $pow from documents""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/pow/test_pow_field_ref_wiring.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/pow/test_pow_field_ref_wiring.py new file mode 100644 index 000000000..593f246ad --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/pow/test_pow_field_ref_wiring.py @@ -0,0 +1,116 @@ +""" +Field-reference wiring tests for $pow expression. + +Confirms field-path resolution feeds correctly into $pow (one type-combo, +one boundary, one null); full matrices are covered as literals elsewhere. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + INT32_MAX, + MISSING, +) + +POW_INSERT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "same_type_int32", + expression={"$pow": ["$base", "$power"]}, + doc={"base": 2, "power": 3}, + expected=8, + msg="Should handle same type int32", + ), + ExpressionTestCase( + "int32_max_base", + expression={"$pow": ["$base", "$power"]}, + doc={"base": INT32_MAX, "power": 1}, + expected=INT32_MAX, + msg="Should handle int32 max base", + ), + ExpressionTestCase( + "null_base", + expression={"$pow": ["$base", "$power"]}, + doc={"base": None, "power": 2}, + expected=None, + msg="Should return null for null base", + ), + ExpressionTestCase( + "missing_base", + expression={"$pow": ["$base", "$power"]}, + doc={"power": 2}, + expected=None, + msg="Should return null for missing base", + ), + ExpressionTestCase( + "missing_exponent", + expression={"$pow": ["$base", "$power"]}, + doc={"base": 2}, + expected=None, + msg="Should return null for missing exponent", + ), +] + + +POW_MIXED_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "same_type_int32", + expression={"$pow": ["$base", 3]}, + doc={"base": 2}, + expected=8, + msg="Should handle same type int32", + ), + ExpressionTestCase( + "int32_max_base", + expression={"$pow": ["$base", 1]}, + doc={"base": INT32_MAX}, + expected=INT32_MAX, + msg="Should handle int32 max base", + ), + ExpressionTestCase( + "null_base", + expression={"$pow": ["$base", 2]}, + doc={"base": None}, + expected=None, + msg="Should return null for null base", + ), + ExpressionTestCase( + "missing_base", + expression={"$pow": ["$base", 2]}, + doc={}, + expected=None, + msg="Should return null for missing base", + ), + ExpressionTestCase( + "missing_exponent", + expression={"$pow": ["$base", MISSING]}, + doc={"base": 2}, + expected=None, + msg="Should return null for missing exponent", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(POW_INSERT_TESTS)) +def test_pow_insert(collection, test): + """Test $pow from documents""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +@pytest.mark.parametrize("test", pytest_params(POW_MIXED_TESTS)) +def test_pow_mixed(collection, test): + """Test $pow from mixed documents and literals""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/pow/test_pow_null_missing_infinity.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/pow/test_pow_null_missing_infinity.py new file mode 100644 index 000000000..e2f56399c --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/pow/test_pow_null_missing_infinity.py @@ -0,0 +1,224 @@ +""" +Null, missing, and Infinity tests for $pow expression. + +Covers null/missing short-circuiting for the base and exponent, plus every +combination of NaN, Infinity, and -Infinity as the base and/or exponent for +double and Decimal128. +""" + +import math + +import pytest +from bson import Decimal128 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_ZERO, + DOUBLE_NEGATIVE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + MISSING, +) + +POW_LITERAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_exponent", + expression={"$pow": [2, None]}, + expected=None, + msg="Should return null for null exponent", + ), + ExpressionTestCase( + "null_base", + expression={"$pow": [None, 2]}, + expected=None, + msg="Should return null for null base", + ), + ExpressionTestCase( + "missing_base", + expression={"$pow": [MISSING, 2]}, + expected=None, + msg="Should return null for missing base", + ), + ExpressionTestCase( + "missing_exponent", + expression={"$pow": [2, MISSING]}, + expected=None, + msg="Should return null for missing exponent", + ), + ExpressionTestCase( + "both_null", + expression={"$pow": [None, None]}, + expected=None, + msg="Should return null for both null", + ), + ExpressionTestCase( + "infinity_base", + expression={"$pow": [FLOAT_INFINITY, 2]}, + expected=float("inf"), + msg="Should handle infinity base", + ), + ExpressionTestCase( + "infinity_exponent", + expression={"$pow": [2, FLOAT_INFINITY]}, + expected=float("inf"), + msg="Should handle infinity exponent", + ), + ExpressionTestCase( + "infinity_to_zero", + expression={"$pow": [FLOAT_INFINITY, 0]}, + expected=1.0, + msg="Should handle infinity to zero", + ), + ExpressionTestCase( + "infinity_negative_exp", + expression={"$pow": [FLOAT_INFINITY, -1]}, + expected=0.0, + msg="Should handle infinity negative exp", + ), + ExpressionTestCase( + "fractional_base_infinity_exponent", + expression={"$pow": [0.5, FLOAT_INFINITY]}, + expected=0.0, + msg="Should handle base in (0,1) raised to +infinity", + ), + ExpressionTestCase( + "base_gt_one_negative_infinity_exponent", + expression={"$pow": [2, FLOAT_NEGATIVE_INFINITY]}, + expected=0.0, + msg="Should handle base greater than one raised to -infinity", + ), + ExpressionTestCase( + "one_base_infinity_exponent", + expression={"$pow": [1, FLOAT_INFINITY]}, + expected=1.0, + msg="Should handle base of one raised to +infinity", + ), + ExpressionTestCase( + "decimal_infinity_base", + expression={"$pow": [DECIMAL128_INFINITY, 2]}, + expected=Decimal128("Infinity"), + msg="Should handle decimal infinity base", + ), + ExpressionTestCase( + "decimal_neg_infinity_odd", + expression={"$pow": [DECIMAL128_NEGATIVE_INFINITY, 3]}, + expected=Decimal128("-Infinity"), + msg="Should handle decimal neg infinity odd", + ), + ExpressionTestCase( + "decimal_neg_infinity_even", + expression={"$pow": [DECIMAL128_NEGATIVE_INFINITY, 2]}, + expected=Decimal128("Infinity"), + msg="Should handle decimal neg infinity even", + ), + ExpressionTestCase( + "neg_inf_negative_exp", + expression={"$pow": [FLOAT_NEGATIVE_INFINITY, -1]}, + expected=-0.0, + msg="Should handle neg inf negative exp", + ), + ExpressionTestCase( + "neg_inf_fractional_exp", + expression={"$pow": [FLOAT_NEGATIVE_INFINITY, 0.5]}, + expected=float("inf"), + msg="Should handle neg inf fractional exp", + ), + ExpressionTestCase( + "decimal_neg_inf_negative_exp", + expression={"$pow": [DECIMAL128_NEGATIVE_INFINITY, -1]}, + expected=Decimal128("0E-6176"), + msg="Should handle decimal neg inf negative exp", + ), + ExpressionTestCase( + "nan_base", + expression={"$pow": [FLOAT_NAN, 2]}, + expected=pytest.approx(math.nan, nan_ok=True), + msg="Should return NaN for nan base", + ), + ExpressionTestCase( + "nan_exponent", + expression={"$pow": [2, FLOAT_NAN]}, + expected=pytest.approx(math.nan, nan_ok=True), + msg="Should return NaN for nan exponent", + ), + ExpressionTestCase( + "both_nan", + expression={"$pow": [FLOAT_NAN, FLOAT_NAN]}, + expected=pytest.approx(math.nan, nan_ok=True), + msg="Should return NaN for both nan", + ), + ExpressionTestCase( + "negative_fractional_exp", + expression={"$pow": [-1, 0.5]}, + expected=pytest.approx(math.nan, nan_ok=True), + msg="Should handle negative fractional exp", + ), + ExpressionTestCase( + "negative_base_fractional_exp", + expression={"$pow": [-2, 2.5]}, + expected=pytest.approx(math.nan, nan_ok=True), + msg="Should handle negative base fractional exp", + ), + ExpressionTestCase( + "decimal_nan_base", + expression={"$pow": [DECIMAL128_NAN, 2]}, + expected=DECIMAL128_NAN, + msg="Should return NaN for decimal nan base", + ), + ExpressionTestCase( + "decimal_nan_exponent", + expression={"$pow": [2, DECIMAL128_NAN]}, + expected=DECIMAL128_NAN, + msg="Should return NaN for decimal nan exponent", + ), + ExpressionTestCase( + "decimal_both_nan", + expression={"$pow": [DECIMAL128_NAN, DECIMAL128_NAN]}, + expected=DECIMAL128_NAN, + msg="Should return NaN for decimal both nan", + ), + ExpressionTestCase( + "double_negative_zero_base_odd_exp", + expression={"$pow": [DOUBLE_NEGATIVE_ZERO, 3]}, + expected=DOUBLE_NEGATIVE_ZERO, + msg="Should preserve negative-zero sign for odd exponent", + ), + ExpressionTestCase( + "double_negative_zero_base_even_exp", + expression={"$pow": [DOUBLE_NEGATIVE_ZERO, 4]}, + expected=0.0, + msg="Should return positive zero for even exponent", + ), + ExpressionTestCase( + "decimal_negative_zero_base_odd_exp", + expression={"$pow": [DECIMAL128_NEGATIVE_ZERO, 3]}, + expected=Decimal128("0E-6176"), + msg="Should not preserve negative-zero sign for decimal128 (unlike double)", + ), + ExpressionTestCase( + "decimal_negative_zero_base_even_exp", + expression={"$pow": [DECIMAL128_NEGATIVE_ZERO, 4]}, + expected=Decimal128("0E-6176"), + msg="Should return zero for decimal128 negative-zero base, even exponent", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(POW_LITERAL_TESTS)) +def test_pow_literal(collection, test): + """Test $pow from literals""" + result = execute_expression(collection, test.expression) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/query/misc/__init__.py b/documentdb_tests/compatibility/tests/core/operator/query/misc/__init__.py new file mode 100644 index 000000000..e69de29bb From 30546fd0b90dcae7611dd3ff0d7a1a1f74a16fa2 Mon Sep 17 00:00:00 2001 From: Daniel Frankcom Date: Wed, 15 Jul 2026 17:04:34 -0700 Subject: [PATCH 35/35] Fix autoCompact intermittent busy error (#689) Signed-off-by: Daniel Frankcom Signed-off-by: RyanGarfinkel <113050972+RyanGarfinkel@users.noreply.github.com> --- .../test_autoCompact_fstmb_bounds.py | 9 +++-- .../test_autoCompact_operational.py | 29 +++++++++++----- .../autoCompact/test_autoCompact_success.py | 8 +++-- .../autoCompact/test_smoke_autoCompact.py | 8 +++-- .../autoCompact/utils/autoCompact_common.py | 33 +++++++------------ documentdb_tests/framework/error_codes.py | 3 +- 6 files changed, 52 insertions(+), 38 deletions(-) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_autoCompact_fstmb_bounds.py b/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_autoCompact_fstmb_bounds.py index 52372da22..e17d9115f 100644 --- a/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_autoCompact_fstmb_bounds.py +++ b/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_autoCompact_fstmb_bounds.py @@ -13,8 +13,8 @@ ensure_autocompact_idle, ) from documentdb_tests.framework.assertions import assertResult -from documentdb_tests.framework.error_codes import BAD_VALUE_ERROR -from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.error_codes import BAD_VALUE_ERROR, OBJECT_IS_BUSY_ERROR +from documentdb_tests.framework.executor import execute_admin_with_retry_command from documentdb_tests.framework.parametrize import pytest_params from documentdb_tests.framework.property_checks import Eq from documentdb_tests.framework.test_constants import ( @@ -285,7 +285,10 @@ def test_autoCompact_fstmb_bounds(database_client, collection, test): # Ensure autoCompact is idle first: a leftover config from a prior test # would otherwise conflict. ensure_autocompact_idle(collection) - result = execute_admin_command(collection, test.build_command(ctx)) + # Tolerate the transient busy state while a prior compaction winds down. + result = execute_admin_with_retry_command( + collection, test.build_command(ctx), retry_code=OBJECT_IS_BUSY_ERROR + ) assertResult( result, expected=test.build_expected(ctx), diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_autoCompact_operational.py b/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_autoCompact_operational.py index bd7a8bde0..df716d06b 100644 --- a/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_autoCompact_operational.py +++ b/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_autoCompact_operational.py @@ -13,11 +13,12 @@ ) from documentdb_tests.framework.assertions import assertResult from documentdb_tests.framework.error_codes import ( - CONFLICTING_OPERATION_IN_PROGRESS_ERROR, + ALREADY_INITIALIZED_ERROR, + OBJECT_IS_BUSY_ERROR, UNAUTHORIZED_ERROR, ) from documentdb_tests.framework.executor import ( - execute_admin_command, + execute_admin_with_retry_command, execute_command, ) from documentdb_tests.framework.parametrize import pytest_params @@ -68,14 +69,19 @@ def test_autoCompact_reconfigure_conflict(collection): ensure_autocompact_idle(collection) # Establish a running config. - execute_admin_command(collection, {"autoCompact": True, "freeSpaceTargetMB": 30}) + execute_admin_with_retry_command( + collection, {"autoCompact": True, "freeSpaceTargetMB": 30}, retry_code=OBJECT_IS_BUSY_ERROR + ) - # Second should be rejected as the value differs. - result = execute_admin_command(collection, {"autoCompact": True, "freeSpaceTargetMB": 50}) + # A differing reconfigure is rejected while running; tolerate the transient + # busy state while a prior compaction winds down. + result = execute_admin_with_retry_command( + collection, {"autoCompact": True, "freeSpaceTargetMB": 50}, retry_code=OBJECT_IS_BUSY_ERROR + ) assertResult( result, - error_code=CONFLICTING_OPERATION_IN_PROGRESS_ERROR, + error_code=ALREADY_INITIALIZED_ERROR, msg="autoCompact should reject reconfiguring an enabled compaction with a different config", raw_res=True, ) @@ -91,10 +97,15 @@ def test_autoCompact_reconfigure_idempotent(collection): ensure_autocompact_idle(collection) # Establish a running config. - execute_admin_command(collection, {"autoCompact": True, "freeSpaceTargetMB": 30}) + execute_admin_with_retry_command( + collection, {"autoCompact": True, "freeSpaceTargetMB": 30}, retry_code=OBJECT_IS_BUSY_ERROR + ) - # Second should be accepted as a no-op since the value is the same. - result = execute_admin_command(collection, {"autoCompact": True, "freeSpaceTargetMB": 30}) + # Re-enabling an identical config is a no-op; tolerate the transient busy + # state while a prior compaction winds down. + result = execute_admin_with_retry_command( + collection, {"autoCompact": True, "freeSpaceTargetMB": 30}, retry_code=OBJECT_IS_BUSY_ERROR + ) assertResult( result, diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_autoCompact_success.py b/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_autoCompact_success.py index b24e6c915..8b60d285a 100644 --- a/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_autoCompact_success.py +++ b/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_autoCompact_success.py @@ -12,7 +12,8 @@ ensure_autocompact_idle, ) from documentdb_tests.framework.assertions import assertResult -from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.error_codes import OBJECT_IS_BUSY_ERROR +from documentdb_tests.framework.executor import execute_admin_with_retry_command from documentdb_tests.framework.parametrize import pytest_params from documentdb_tests.framework.property_checks import Eq, IsType, NotExists @@ -116,7 +117,10 @@ def test_autoCompact_success(database_client, collection, test): # Ensure autoCompact is idle first: a leftover config from a prior test # would otherwise conflict. ensure_autocompact_idle(collection) - result = execute_admin_command(collection, test.build_command(ctx)) + # Tolerate the transient busy state while a prior compaction winds down. + result = execute_admin_with_retry_command( + collection, test.build_command(ctx), retry_code=OBJECT_IS_BUSY_ERROR + ) assertResult( result, expected=test.build_expected(ctx), diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_smoke_autoCompact.py b/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_smoke_autoCompact.py index 5bb8b7954..26aba9f12 100644 --- a/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_smoke_autoCompact.py +++ b/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_smoke_autoCompact.py @@ -10,7 +10,8 @@ ensure_autocompact_idle, ) from documentdb_tests.framework.assertions import assertSuccessPartial -from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.error_codes import OBJECT_IS_BUSY_ERROR +from documentdb_tests.framework.executor import execute_admin_with_retry_command pytestmark = [pytest.mark.smoke, pytest.mark.no_parallel] @@ -20,7 +21,10 @@ def test_smoke_autoCompact(collection): # Ensure autoCompact is idle first: a leftover non-default config would make # this plain enable conflict instead of returning ok. ensure_autocompact_idle(collection) - result = execute_admin_command(collection, {"autoCompact": True}) + # Tolerate the transient busy state while a prior compaction winds down. + result = execute_admin_with_retry_command( + collection, {"autoCompact": True}, retry_code=OBJECT_IS_BUSY_ERROR + ) expected = {"ok": 1.0} assertSuccessPartial(result, expected, msg="Should support autoCompact command") diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/utils/autoCompact_common.py b/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/utils/autoCompact_common.py index 4b325ed7a..ee9b6a833 100644 --- a/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/utils/autoCompact_common.py +++ b/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/utils/autoCompact_common.py @@ -2,32 +2,23 @@ from __future__ import annotations -import time - -from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.error_codes import OBJECT_IS_BUSY_ERROR +from documentdb_tests.framework.executor import execute_admin_with_retry_command def ensure_autocompact_idle(collection): - """Disable autoCompact, retrying until it reaches a deterministic idle state. + """Reset autoCompact to a disabled baseline before a test runs. - autoCompact is a server-wide setting, so a test inherits prior state, and a - single disable returns before the background wind-down finishes. This sends - disable repeatedly with a short pause between calls, returns only after - several consecutive disables succeed (so the async wind-down has time to - finish), and raises if it never settles within a bounded number of attempts. + autoCompact is a server-wide setting, so a test inherits prior state. The + background compaction winds down asynchronously, so we must try to disable + until the server stops complaining. Callers must be marked no_parallel: this only resets state left by a prior - test, not a concurrent worker mutating the shared setting between settling + test, not a concurrent worker mutating the shared setting between the reset and the command under test. """ - consecutive = 0 - for _ in range(200): - result = execute_admin_command(collection, {"autoCompact": False}) - if isinstance(result, dict) and result.get("ok") == 1.0: - consecutive += 1 - if consecutive >= 3: - return - else: - consecutive = 0 - time.sleep(0.05) - raise RuntimeError("autoCompact did not reach an idle state") + result = execute_admin_with_retry_command( + collection, {"autoCompact": False}, retry_code=OBJECT_IS_BUSY_ERROR + ) + if not (isinstance(result, dict) and result.get("ok") == 1.0): + raise RuntimeError(f"autoCompact did not reach an idle state: {result!r}") diff --git a/documentdb_tests/framework/error_codes.py b/documentdb_tests/framework/error_codes.py index ef220d300..8a71478fa 100644 --- a/documentdb_tests/framework/error_codes.py +++ b/documentdb_tests/framework/error_codes.py @@ -14,7 +14,7 @@ OVERFLOW_ERROR = 15 INVALID_LENGTH_ERROR = 16 ILLEGAL_OPERATION_ERROR = 20 -CONFLICTING_OPERATION_IN_PROGRESS_ERROR = 23 +ALREADY_INITIALIZED_ERROR = 23 NAMESPACE_NOT_FOUND_ERROR = 26 INDEX_NOT_FOUND_ERROR = 27 PATH_NOT_VIABLE_ERROR = 28 @@ -66,6 +66,7 @@ NO_QUERY_EXECUTION_PLANS_ERROR = 291 QUERY_EXCEEDED_MEMORY_NO_DISK_USE_ERROR = 292 FEATURE_NOT_SUPPORTED_ERROR = 303 +OBJECT_IS_BUSY_ERROR = 314 API_VERSION_ERROR = 322 API_STRICT_ERROR = 323 CANNOT_CONVERT_INDEX_TO_UNIQUE_ERROR = 359