From 40ec48e7d2f25352cfd72d04f002e1bc10256923 Mon Sep 17 00:00:00 2001 From: PatersonProjects Date: Wed, 8 Jul 2026 09:49:17 -0700 Subject: [PATCH 1/4] Reset to restore to main Signed-off-by: PatersonProjects --- .../operations/test_operations_bulk_write.py | 106 +-- .../collMod/test_collMod_capped_max.py | 335 ++++++++++ .../collMod/test_collMod_capped_size.py | 347 ++++++++++ .../collMod/test_collMod_change_streams.py | 235 +++++++ .../commands/collMod/test_collMod_comment.py | 91 +++ .../commands/collMod/test_collMod_dry_run.py | 278 ++++++++ .../collMod/test_collMod_index_argument.py | 155 +++++ .../collMod/test_collMod_index_expire.py | 418 ++++++++++++ .../collMod/test_collMod_index_hidden.py | 274 ++++++++ .../collMod/test_collMod_index_identifier.py | 321 +++++++++ .../test_collMod_index_prepare_unique.py | 317 +++++++++ .../collMod/test_collMod_index_unique.py | 241 +++++++ .../collMod/test_collMod_interactions.py | 417 ++++++++++++ .../commands/collMod/test_collMod_pipeline.py | 412 ++++++++++++ .../collMod/test_collMod_read_concern.py | 201 ++++++ .../collMod/test_collMod_target_name.py | 151 +++++ .../test_collMod_time_series_bucketing.py | 369 +++++++++++ .../test_collMod_time_series_document.py | 171 +++++ .../test_collMod_time_series_expire.py | 371 +++++++++++ .../test_collMod_time_series_granularity.py | 190 ++++++ .../test_collMod_validation_level_action.py | 254 +++++++ .../collMod/test_collMod_validator.py | 447 +++++++++++++ .../commands/collMod/test_collMod_view_on.py | 184 +++++ .../collMod/test_collMod_write_concern.py | 344 ++++++++++ .../test_changeStream_early_start.py | 9 +- .../test_changeStream_timestamp_boundary.py | 9 +- .../operator/update/array/push/__init__.py | 0 .../push/test_push_bson_type_validation.py | 95 +++ .../update/array/push/test_push_commands.py | 139 ++++ .../update/array/push/test_push_core.py | 204 ++++++ .../update/array/push/test_push_errors.py | 23 + .../test_update_modifier_integration.py | 8 + .../planCacheClearFilters/__init__.py | 0 .../test_planCacheClearFilters_behavior.py | 96 +++ .../test_planCacheClearFilters_core.py | 291 ++++++++ .../test_planCacheClearFilters_edge_cases.py | 209 ++++++ .../test_planCacheClearFilters_errors.py | 192 ++++++ .../test_planCacheClearFilters_field_types.py | 117 ++++ .../commands/planCacheListFilters/__init__.py | 0 .../test_planCacheListFilters_core.py | 260 ++++++++ .../test_planCacheListFilters_errors.py | 194 ++++++ .../test_planCacheListFilters_lifecycle.py | 270 ++++++++ .../test_planCacheListFilters_response.py | 350 ++++++++++ .../test_bulkWrite_argument_validation.py | 113 ++++ .../test_bulkWrite_bson_type_validation.py | 380 +++++++++++ .../test_bulkWrite_bypass_validation.py | 389 +++++++++++ .../bulkWrite/test_bulkWrite_core_delete.py | 106 +++ .../bulkWrite/test_bulkWrite_core_insert.py | 313 +++++++++ .../bulkWrite/test_bulkWrite_core_update.py | 281 ++++++++ .../bulkWrite/test_bulkWrite_errors.py | 626 ++++++++++++++++++ .../bulkWrite/test_bulkWrite_let_variables.py | 228 +++++++ .../test_bulkWrite_mixed_operations.py | 208 ++++++ .../test_bulkWrite_response_structure.py | 200 ++++++ .../bulkWrite/test_bulkWrite_sub_features.py | 203 ++++++ .../test_findAndModify_argument_validation.py | 190 ++++++ ...test_findAndModify_bson_type_validation.py | 240 +++++++ .../test_findAndModify_core_behavior.py | 491 ++++++++++++++ .../test_findAndModify_data_types.py | 188 ++++++ ...st_findAndModify_dollar_prefixed_fields.py | 77 +++ .../test_findAndModify_errors.py | 514 ++++++++++++++ .../test_findAndModify_expr_let.py | 116 ++++ .../test_findAndModify_projection.py | 170 +++++ .../test_findAndModify_update_modes.py | 289 ++++++++ .../test_findAndModify_upsert.py | 461 +++++++++++++ .../test_findAndModify_with_expr.py | 46 -- .../test_read_concern_bson_type_validation.py | 82 +++ .../test_read_concern_command_interaction.py | 134 ++++ .../read_concern/test_read_concern_errors.py | 134 ++++ .../test_read_concern_level_acceptance.py | 94 +++ .../read_concern/test_read_concern_local.py | 77 +++ .../test_read_concern_write_commands.py | 70 ++ .../read_concern/test_smoke_read_concern.py | 6 +- .../write_concern/test_smoke_write_concern.py | 36 +- .../test_write_concern_acceptance.py | 235 +++++++ .../test_write_concern_behavior.py | 186 ++++++ ...test_write_concern_bson_type_validation.py | 112 ++++ .../test_write_concern_errors.py | 281 ++++++++ .../test_write_concern_replica_set.py | 53 ++ .../commands/removeQuerySettings/__init__.py | 0 .../test_removeQuerySettings_behavior.py | 584 ++++++++++++++++ .../test_removeQuerySettings_core.py | 311 +++++++++ .../test_removeQuerySettings_error.py | 212 ++++++ .../compatibility/tests/system/__init__.py | 0 .../tests/system/administration/__init__.py | 0 .../administration/commands/__init__.py | 0 .../commands/autoCompact/__init__.py | 0 .../test_autoCompact_fstmb_bounds.py | 295 +++++++++ .../test_autoCompact_fstmb_overflow.py | 169 +++++ .../test_autoCompact_operational.py | 104 +++ .../test_autoCompact_request_validation.py | 221 +++++++ .../autoCompact/test_autoCompact_success.py | 126 ++++ .../autoCompact/test_smoke_autoCompact.py | 8 +- .../commands/autoCompact/utils/__init__.py | 0 .../autoCompact/utils/autoCompact_common.py | 33 + .../__init__.py | 0 ...tStructuredEncryptionData_core_behavior.py | 80 +++ ...pactStructuredEncryptionData_edge_cases.py | 70 ++ ...actStructuredEncryptionData_error_cases.py | 92 +++ ...ructuredEncryptionData_field_validation.py | 109 +++ ...tStructuredEncryptionData_qe_collection.py | 134 ++++ ...t_smoke_compactStructuredEncryptionData.py | 13 +- .../commands/fsyncUnlock/conftest.py | 11 + .../commands/fsyncUnlock/test_fsyncUnlock.py | 366 ++++++++++ ...est_getDefaultRWConcern_command_options.py | 229 +++++++ .../test_getDefaultRWConcern_read_concern.py | 264 ++++++++ .../test_getDefaultRWConcern_response.py | 139 ++++ .../test_getDefaultRWConcern_write_concern.py | 106 +++ .../commands/getParameter/__init__.py | 0 .../test_getParameter_bson_type_validation.py | 143 ++++ .../test_getParameter_core_behavior.py | 348 ++++++++++ .../getParameter/test_getParameter_errors.py | 169 +++++ .../commands/killOp/__init__.py | 0 .../test_killOp_bson_type_validation.py | 114 ++++ .../killOp/test_killOp_core_behavior.py | 90 +++ .../commands/killOp/test_killOp_errors.py | 117 ++++ .../commands/logRotate/__init__.py | 0 .../test_logRotate_bson_type_validation.py | 99 +++ .../logRotate/test_logRotate_errors.py | 86 +++ .../test_logRotate_value_acceptance.py | 37 ++ .../logRotate/test_smoke_logRotate.py | 17 +- .../system/administration/utils/__init__.py | 0 .../administration/utils/admin_test_case.py | 22 + .../system/administration/utils/fsync_lock.py | 40 ++ .../buildInfo/test_buildInfo_consistency.py | 12 +- .../commands/getCmdLineOpts/__init__.py | 0 .../test_getCmdLineOpts_argument_handling.py | 66 ++ .../test_getCmdLineOpts_consistency.py | 35 + .../test_getCmdLineOpts_error_conditions.py | 63 ++ .../test_getCmdLineOpts_response_structure.py | 47 ++ .../diagnostic/commands/getLog/__init__.py | 0 .../getLog/test_getLog_argument_validation.py | 41 ++ .../commands/getLog/test_getLog_errors.py | 77 +++ .../getLog/test_getLog_response_structure.py | 104 +++ .../diagnostic/commands/hostInfo/__init__.py | 0 .../test_hostInfo_argument_handling.py | 37 ++ .../hostInfo/test_hostInfo_consistency.py | 51 ++ .../test_hostInfo_error_conditions.py | 47 ++ .../test_hostInfo_response_structure.py | 171 +++++ .../commands/listCommands/__init__.py | 0 .../test_listCommands_argument_handling.py | 64 ++ .../test_listCommands_consistency.py | 75 +++ .../test_listCommands_error_conditions.py | 47 ++ .../test_listCommands_response_structure.py | 124 ++++ .../diagnostic/commands/lockInfo/__init__.py | 1 + .../test_lockInfo_argument_validation.py | 78 +++ .../commands/lockInfo/test_lockInfo_errors.py | 56 ++ .../test_lockInfo_response_structure.py | 64 ++ .../diagnostic/commands/top/__init__.py | 0 .../top/test_top_argument_handling.py | 157 +++++ .../commands/top/test_top_consistency.py | 178 +++++ .../commands/top/test_top_core_behavior.py | 216 ++++++ .../commands/top/test_top_errors.py | 64 ++ .../top/test_top_response_structure.py | 116 ++++ .../diagnostic/commands/validate/__init__.py | 0 .../test_validate_bool_param_coercion.py | 168 +++++ ...test_validate_checkBSONConformance_type.py | 50 ++ .../validate/test_validate_core_behavior.py | 245 +++++++ .../validate/test_validate_edge_cases.py | 178 +++++ .../validate/test_validate_error_cases.py | 283 ++++++++ .../validate/test_validate_indexes.py | 232 +++++++ .../validate/test_validate_metadata_type.py | 96 +++ .../commands/validate/test_validate_repair.py | 106 +++ .../test_validate_response_structure.py | 289 ++++++++ .../diagnostic/utils/diagnostic_test_case.py | 13 +- .../tests/system/replication/__init__.py | 0 .../system/replication/commands/__init__.py | 0 .../replication/commands/hello/__init__.py | 0 .../hello/test_hello_command_value.py | 71 ++ .../commands/hello/test_hello_comment.py | 122 ++++ .../commands/hello/test_hello_consistency.py | 232 +++++++ .../commands/hello/test_hello_error_cases.py | 109 +++ .../commands/hello/test_hello_replica_set.py | 190 ++++++ .../hello/test_hello_response_structure.py | 165 +++++ .../hello/test_hello_sasl_supported_mechs.py | 140 ++++ .../commands/hello/test_smoke_hello.py | 21 + .../utils/replication_test_case.py | 24 + documentdb_tests/framework/error_codes.py | 8 + documentdb_tests/framework/executor.py | 29 + documentdb_tests/framework/preconditions.py | 8 + documentdb_tests/framework/property_checks.py | 36 + documentdb_tests/framework/test_constants.py | 4 + .../framework/test_format_validator.py | 1 + .../framework/test_structure_validator.py | 2 +- 183 files changed, 26054 insertions(+), 141 deletions(-) create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_capped_max.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_capped_size.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_change_streams.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_comment.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_dry_run.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_index_argument.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_index_expire.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_index_hidden.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_index_identifier.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_index_prepare_unique.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_index_unique.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_interactions.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_pipeline.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_read_concern.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_target_name.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_time_series_bucketing.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_time_series_document.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_time_series_expire.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_time_series_granularity.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_validation_level_action.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_validator.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_view_on.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_write_concern.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/update/array/push/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/update/array/push/test_push_bson_type_validation.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/update/array/push/test_push_commands.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/update/array/push/test_push_core.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/update/array/push/test_push_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClearFilters/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClearFilters/test_planCacheClearFilters_behavior.py create mode 100644 documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClearFilters/test_planCacheClearFilters_core.py create mode 100644 documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClearFilters/test_planCacheClearFilters_edge_cases.py create mode 100644 documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClearFilters/test_planCacheClearFilters_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClearFilters/test_planCacheClearFilters_field_types.py create mode 100644 documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheListFilters/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheListFilters/test_planCacheListFilters_core.py create mode 100644 documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheListFilters/test_planCacheListFilters_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheListFilters/test_planCacheListFilters_lifecycle.py create mode 100644 documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheListFilters/test_planCacheListFilters_response.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_argument_validation.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_bson_type_validation.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_bypass_validation.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_core_delete.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_core_insert.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_core_update.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_let_variables.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_mixed_operations.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_response_structure.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_sub_features.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_argument_validation.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_bson_type_validation.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_core_behavior.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_data_types.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_dollar_prefixed_fields.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_expr_let.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_projection.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_update_modes.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_upsert.py delete mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_with_expr.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_bson_type_validation.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_command_interaction.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_level_acceptance.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_local.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_write_commands.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_write_concern_acceptance.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_write_concern_behavior.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_write_concern_bson_type_validation.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_write_concern_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_write_concern_replica_set.py create mode 100644 documentdb_tests/compatibility/tests/core/query_planning/commands/removeQuerySettings/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/query_planning/commands/removeQuerySettings/test_removeQuerySettings_behavior.py create mode 100644 documentdb_tests/compatibility/tests/core/query_planning/commands/removeQuerySettings/test_removeQuerySettings_core.py create mode 100644 documentdb_tests/compatibility/tests/core/query_planning/commands/removeQuerySettings/test_removeQuerySettings_error.py create mode 100644 documentdb_tests/compatibility/tests/system/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_autoCompact_fstmb_bounds.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_autoCompact_fstmb_overflow.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_autoCompact_operational.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_autoCompact_request_validation.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_autoCompact_success.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/utils/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/utils/autoCompact_common.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/compactStructuredEncryptionData/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/compactStructuredEncryptionData/test_compactStructuredEncryptionData_core_behavior.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/compactStructuredEncryptionData/test_compactStructuredEncryptionData_edge_cases.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/compactStructuredEncryptionData/test_compactStructuredEncryptionData_error_cases.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/compactStructuredEncryptionData/test_compactStructuredEncryptionData_field_validation.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/compactStructuredEncryptionData/test_compactStructuredEncryptionData_qe_collection.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/fsyncUnlock/conftest.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/fsyncUnlock/test_fsyncUnlock.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/getDefaultRWConcern/test_getDefaultRWConcern_command_options.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/getDefaultRWConcern/test_getDefaultRWConcern_read_concern.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/getDefaultRWConcern/test_getDefaultRWConcern_response.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/getDefaultRWConcern/test_getDefaultRWConcern_write_concern.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/getParameter/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/getParameter/test_getParameter_bson_type_validation.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/getParameter/test_getParameter_core_behavior.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/getParameter/test_getParameter_errors.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/killOp/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/killOp/test_killOp_bson_type_validation.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/killOp/test_killOp_core_behavior.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/killOp/test_killOp_errors.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/logRotate/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/logRotate/test_logRotate_bson_type_validation.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/logRotate/test_logRotate_errors.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/logRotate/test_logRotate_value_acceptance.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/utils/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/utils/admin_test_case.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/utils/fsync_lock.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/getCmdLineOpts/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/getCmdLineOpts/test_getCmdLineOpts_argument_handling.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/getCmdLineOpts/test_getCmdLineOpts_consistency.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/getCmdLineOpts/test_getCmdLineOpts_error_conditions.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/getCmdLineOpts/test_getCmdLineOpts_response_structure.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/getLog/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/getLog/test_getLog_argument_validation.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/getLog/test_getLog_errors.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/getLog/test_getLog_response_structure.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/hostInfo/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/hostInfo/test_hostInfo_argument_handling.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/hostInfo/test_hostInfo_consistency.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/hostInfo/test_hostInfo_error_conditions.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/hostInfo/test_hostInfo_response_structure.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/listCommands/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/listCommands/test_listCommands_argument_handling.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/listCommands/test_listCommands_consistency.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/listCommands/test_listCommands_error_conditions.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/listCommands/test_listCommands_response_structure.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/lockInfo/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/lockInfo/test_lockInfo_argument_validation.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/lockInfo/test_lockInfo_errors.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/lockInfo/test_lockInfo_response_structure.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/top/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/top/test_top_argument_handling.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/top/test_top_consistency.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/top/test_top_core_behavior.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/top/test_top_errors.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/top/test_top_response_structure.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_bool_param_coercion.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_checkBSONConformance_type.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_core_behavior.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_edge_cases.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_error_cases.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_indexes.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_metadata_type.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_repair.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_response_structure.py create mode 100644 documentdb_tests/compatibility/tests/system/replication/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/replication/commands/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/replication/commands/hello/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/replication/commands/hello/test_hello_command_value.py create mode 100644 documentdb_tests/compatibility/tests/system/replication/commands/hello/test_hello_comment.py create mode 100644 documentdb_tests/compatibility/tests/system/replication/commands/hello/test_hello_consistency.py create mode 100644 documentdb_tests/compatibility/tests/system/replication/commands/hello/test_hello_error_cases.py create mode 100644 documentdb_tests/compatibility/tests/system/replication/commands/hello/test_hello_replica_set.py create mode 100644 documentdb_tests/compatibility/tests/system/replication/commands/hello/test_hello_response_structure.py create mode 100644 documentdb_tests/compatibility/tests/system/replication/commands/hello/test_hello_sasl_supported_mechs.py create mode 100644 documentdb_tests/compatibility/tests/system/replication/commands/hello/test_smoke_hello.py diff --git a/documentdb_tests/compatibility/tests/core/collation/command_level/operations/test_operations_bulk_write.py b/documentdb_tests/compatibility/tests/core/collation/command_level/operations/test_operations_bulk_write.py index 49a630c78..1e6fbc44d 100644 --- a/documentdb_tests/compatibility/tests/core/collation/command_level/operations/test_operations_bulk_write.py +++ b/documentdb_tests/compatibility/tests/core/collation/command_level/operations/test_operations_bulk_write.py @@ -1,4 +1,4 @@ -"""Tests for collation in bulkWrite operations.""" +"""Tests for collation in the bulkWrite command.""" from __future__ import annotations @@ -8,13 +8,14 @@ CommandContext, CommandTestCase, ) -from documentdb_tests.framework.assertions import assertResult -from documentdb_tests.framework.executor import execute_command +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.target_collection import CustomCollection # Property [BulkWrite Update Collation]: individual update operations within a -# bulkWrite can specify collation, affecting filter matching independently. +# bulkWrite command can specify collation, affecting filter matching +# independently of other operations in the same command. COLLATION_BULK_UPDATE_TESTS: list[CommandTestCase] = [ CommandTestCase( "bulk_update_case_insensitive", @@ -23,21 +24,24 @@ {"_id": 2, "x": "banana", "v": 1}, ], command=lambda ctx: { - "update": ctx.collection, - "updates": [ + "bulkWrite": 1, + "ops": [ { - "q": {"x": "apple"}, - "u": {"$set": {"v": 2}}, + "update": 0, + "filter": {"x": "apple"}, + "updateMods": {"$set": {"v": 2}}, "collation": {"locale": "en", "strength": 2}, }, { - "q": {"x": "BANANA"}, - "u": {"$set": {"v": 3}}, + "update": 0, + "filter": {"x": "BANANA"}, + "updateMods": {"$set": {"v": 3}}, "collation": {"locale": "en", "strength": 2}, }, ], + "nsInfo": [{"ns": ctx.namespace}], }, - expected={"ok": 1.0, "n": 2, "nModified": 2}, + expected={"ok": 1.0, "nMatched": 2, "nModified": 2}, msg="bulkWrite updates should each use their own collation", ), CommandTestCase( @@ -47,26 +51,30 @@ {"_id": 2, "x": "banana", "v": 1}, ], command=lambda ctx: { - "update": ctx.collection, - "updates": [ + "bulkWrite": 1, + "ops": [ { - "q": {"x": "apple"}, - "u": {"$set": {"v": 2}}, + "update": 0, + "filter": {"x": "apple"}, + "updateMods": {"$set": {"v": 2}}, "collation": {"locale": "en", "strength": 2}, }, { - "q": {"x": "BANANA"}, - "u": {"$set": {"v": 3}}, + "update": 0, + "filter": {"x": "BANANA"}, + "updateMods": {"$set": {"v": 3}}, }, ], + "nsInfo": [{"ns": ctx.namespace}], }, - expected={"ok": 1.0, "n": 1, "nModified": 1}, + expected={"ok": 1.0, "nMatched": 1, "nModified": 1}, msg="bulkWrite with mixed collation: only collated op should match case-insensitively", ), ] # Property [BulkWrite Delete Collation]: individual delete operations within a -# bulkWrite can specify collation, affecting filter matching independently. +# bulkWrite command can specify collation, affecting filter matching +# independently of other operations in the same command. COLLATION_BULK_DELETE_TESTS: list[CommandTestCase] = [ CommandTestCase( "bulk_delete_case_insensitive", @@ -76,16 +84,18 @@ {"_id": 3, "x": "cherry"}, ], command=lambda ctx: { - "delete": ctx.collection, - "deletes": [ + "bulkWrite": 1, + "ops": [ { - "q": {"x": "apple"}, - "limit": 0, + "delete": 0, + "filter": {"x": "apple"}, + "multi": True, "collation": {"locale": "en", "strength": 2}, }, ], + "nsInfo": [{"ns": ctx.namespace}], }, - expected={"ok": 1.0, "n": 1}, + expected={"ok": 1.0, "nDeleted": 1}, msg="bulkWrite delete with collation should match case-insensitively", ), CommandTestCase( @@ -96,15 +106,17 @@ {"_id": 3, "x": "banana"}, ], command=lambda ctx: { - "delete": ctx.collection, - "deletes": [ + "bulkWrite": 1, + "ops": [ { - "q": {"x": "apple"}, - "limit": 0, + "delete": 0, + "filter": {"x": "apple"}, + "multi": True, }, ], + "nsInfo": [{"ns": ctx.namespace}], }, - expected={"ok": 1.0, "n": 1}, + expected={"ok": 1.0, "nDeleted": 1}, msg="bulkWrite delete without collation should use binary comparison", ), ] @@ -120,15 +132,17 @@ {"_id": 2, "x": "banana", "v": 1}, ], command=lambda ctx: { - "update": ctx.collection, - "updates": [ + "bulkWrite": 1, + "ops": [ { - "q": {"x": "apple"}, - "u": {"$set": {"v": 2}}, + "update": 0, + "filter": {"x": "apple"}, + "updateMods": {"$set": {"v": 2}}, }, ], + "nsInfo": [{"ns": ctx.namespace}], }, - expected={"ok": 1.0, "n": 1, "nModified": 1}, + expected={"ok": 1.0, "nMatched": 1, "nModified": 1}, msg="bulkWrite update should inherit collection default collation", ), CommandTestCase( @@ -139,15 +153,17 @@ {"_id": 2, "x": "banana"}, ], command=lambda ctx: { - "delete": ctx.collection, - "deletes": [ + "bulkWrite": 1, + "ops": [ { - "q": {"x": "apple"}, - "limit": 0, + "delete": 0, + "filter": {"x": "apple"}, + "multi": True, }, ], + "nsInfo": [{"ns": ctx.namespace}], }, - expected={"ok": 1.0, "n": 1}, + expected={"ok": 1.0, "nDeleted": 1}, msg="bulkWrite delete should inherit collection default collation", ), ] @@ -161,14 +177,8 @@ @pytest.mark.parametrize("test", pytest_params(COLLATION_BULK_WRITE_TESTS)) def test_collation_bulk_write(database_client, collection, test): - """Test collation behavior in bulkWrite operations.""" + """Test collation behavior in the bulkWrite command.""" 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, - ) + 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/core/collections/commands/collMod/test_collMod_capped_max.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_capped_max.py new file mode 100644 index 000000000..c6f54d567 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_capped_max.py @@ -0,0 +1,335 @@ +"""Tests for collMod cappedMax and cappedSize/cappedMax coexistence on capped collections.""" + +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, + INVALID_OPTIONS_ERROR, + NAMESPACE_NOT_FOUND_ERROR, + 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 +from documentdb_tests.framework.target_collection import ( + CappedCollection, + ClusteredCollection, + ExistingDatabase, + ViewCollection, +) +from documentdb_tests.framework.test_constants import ( + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + INT32_MAX, + INT32_OVERFLOW, +) + +# Property [cappedMax Numeric Type Acceptance]: cappedMax accepts any numeric type. +COLLMOD_CAPPED_MAX_NUMERIC_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "max_numeric_int32", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedMax": 1_000}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept an int32 cappedMax", + ), + CommandTestCase( + "max_numeric_int64", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedMax": Int64(1_000)}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept an int64 cappedMax", + ), + CommandTestCase( + "max_numeric_double", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedMax": 1_000.0}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a double cappedMax", + ), + CommandTestCase( + "max_numeric_decimal", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedMax": Decimal128("1000")}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a decimal128 cappedMax", + ), +] + +# Property [cappedMax Null No-Op]: a null cappedMax is accepted as a no-op. +COLLMOD_CAPPED_MAX_NULL_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "max_null", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedMax": None}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a null cappedMax as a no-op", + ), +] + +# Property [cappedMax No Lower Bound]: any value at or below 0, NaN, or +# -Infinity is accepted and means "no document limit"; cappedMax has no lower +# bound, unlike cappedSize. +COLLMOD_CAPPED_MAX_NO_LOWER_BOUND_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "max_zero", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedMax": 0}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a cappedMax of 0 as no document limit", + ), + CommandTestCase( + "max_negative", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedMax": -1}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a negative cappedMax as no document limit", + ), + CommandTestCase( + "max_nan", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedMax": FLOAT_NAN}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a NaN cappedMax as no document limit", + ), + CommandTestCase( + "max_negative_infinity", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedMax": FLOAT_NEGATIVE_INFINITY}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a -Infinity cappedMax as no document limit", + ), +] + +# Property [cappedMax Upper Boundary]: the boundary value INT32_MAX is accepted. +COLLMOD_CAPPED_MAX_BOUNDARY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "max_upper_boundary_int32_max", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedMax": INT32_MAX}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept the upper boundary cappedMax of INT32_MAX", + ), +] + +# Property [cappedMax Fractional Coercion]: a fractional cappedMax is coerced to +# an integer before the range check, with a double truncating toward zero and a +# decimal128 using banker's (round-half-to-even) rounding; a value that lands +# below the exclusive upper bound is accepted. +COLLMOD_CAPPED_MAX_FRACTIONAL_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "max_double_truncates_toward_zero", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedMax": INT32_MAX + 0.5}, + expected={"ok": Eq(1.0)}, + msg="collMod should truncate a fractional double cappedMax toward zero to INT32_MAX, " + "below the exclusive upper bound", + ), + CommandTestCase( + "max_decimal_bankers_rounds_down", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedMax": Decimal128(f"{INT32_MAX}.4")}, + expected={"ok": Eq(1.0)}, + msg="collMod should banker's-round a fractional decimal128 cappedMax down to INT32_MAX, " + "below the exclusive upper bound", + ), +] + +# Property [cappedMax Type Rejection]: any non-numeric cappedMax type produces a +# TypeMismatch error. +COLLMOD_CAPPED_MAX_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"max_type_reject_{tid}", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx, v=val: {"collMod": ctx.collection, "cappedMax": v}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} cappedMax as a non-numeric type", + ) + for tid, val in [ + ("string", "1000"), + ("bool_true", True), + ("bool_false", False), + ("array", [1000]), + ("object", {"x": 1}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"hello")), + ("regex", Regex("abc", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [cappedMax Strict Upper Bound]: a value at or above the upper bound +# produces a BadValue error, since the bound is exclusive unlike cappedSize's +# inclusive bound, including a decimal128 that banker's-rounds up onto the bound +# and +Infinity which coerces above the bound. +COLLMOD_CAPPED_MAX_HIGH_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "max_high_at_bound", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedMax": INT32_OVERFLOW}, + error_code=BAD_VALUE_ERROR, + msg="collMod should reject a cappedMax of 2^31 as at or above the strict upper bound", + ), + CommandTestCase( + "max_high_decimal_bankers_rounds_onto_bound", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedMax": Decimal128(f"{INT32_MAX}.5")}, + error_code=BAD_VALUE_ERROR, + msg="collMod should reject a decimal128 cappedMax that banker's-rounds half-to-even up " + "onto the strict upper bound", + ), + CommandTestCase( + "max_high_positive_infinity", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedMax": FLOAT_INFINITY}, + error_code=BAD_VALUE_ERROR, + msg="collMod should reject a +Infinity cappedMax, which coerces to INT64_MAX, " + "as above the upper bound", + ), +] + +# Property [cappedMax Target Collection Restrictions]: cappedMax is rejected on +# any non-capped target: a regular or clustered collection produces an +# InvalidOptions error, a non-existent collection produces a NamespaceNotFound +# error, and the oplog produces an InvalidOptions error. +COLLMOD_CAPPED_MAX_TARGET_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "max_target_regular", + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedMax": 1_000}, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject cappedMax on a regular collection", + ), + CommandTestCase( + "max_target_clustered", + target_collection=ClusteredCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedMax": 1_000}, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject cappedMax on a clustered collection", + ), + CommandTestCase( + "max_target_nonexistent", + docs=None, + command=lambda ctx: {"collMod": ctx.collection, "cappedMax": 1_000}, + error_code=NAMESPACE_NOT_FOUND_ERROR, + msg="collMod should reject cappedMax on a non-existent collection", + ), + CommandTestCase( + "max_target_oplog", + target_collection=ExistingDatabase(db_name="local"), + docs=None, + command=lambda ctx: {"collMod": "oplog.rs", "cappedMax": 1_000}, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject cappedMax on the oplog", + marks=(pytest.mark.requires(oplog=True),), + ), +] + +# Property [cappedMax View Crash]: applying cappedMax to a view must not crash +# the engine and must return a clean InvalidOptions error; the reference engine +# is skipped because it crashes (SIGSEGV) on this input. +COLLMOD_CAPPED_MAX_VIEW_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "max_target_view", + target_collection=ViewCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedMax": 1_000}, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject cappedMax on a view with a clean error and not crash", + marks=( + pytest.mark.engine_xcrash( + engine="mongodb", + reason="Server crashes (SIGSEGV) when cappedMax is applied to a view", + ), + ), + ), +] + +# Property [cappedSize And cappedMax Coexistence]: cappedSize and cappedMax, the +# two capped-group sub-options, apply together in one command on a capped +# collection, each taking effect independently. +COLLMOD_CAPPED_SIZE_AND_MAX_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "size_and_max_together", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "cappedSize": 16384, + "cappedMax": 99, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should apply cappedSize and cappedMax together in one command", + ), +] + +COLLMOD_CAPPED_MAX_TESTS: list[CommandTestCase] = ( + COLLMOD_CAPPED_MAX_NUMERIC_TESTS + + COLLMOD_CAPPED_MAX_NULL_TESTS + + COLLMOD_CAPPED_MAX_NO_LOWER_BOUND_TESTS + + COLLMOD_CAPPED_MAX_BOUNDARY_TESTS + + COLLMOD_CAPPED_MAX_FRACTIONAL_TESTS + + COLLMOD_CAPPED_MAX_TYPE_ERROR_TESTS + + COLLMOD_CAPPED_MAX_HIGH_ERROR_TESTS + + COLLMOD_CAPPED_MAX_TARGET_ERROR_TESTS + + COLLMOD_CAPPED_MAX_VIEW_ERROR_TESTS + + COLLMOD_CAPPED_SIZE_AND_MAX_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_CAPPED_MAX_TESTS)) +def test_collMod_capped_max(database_client, collection, test): + """Test collMod cappedMax acceptance, rejection, and coexistence with cappedSize.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_capped_size.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_capped_size.py new file mode 100644 index 000000000..6723a3b6b --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_capped_size.py @@ -0,0 +1,347 @@ +"""Tests for collMod cappedSize on capped collections.""" + +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, + INVALID_OPTIONS_ERROR, + NAMESPACE_NOT_FOUND_ERROR, + 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 +from documentdb_tests.framework.target_collection import ( + CappedCollection, + ClusteredCollection, + ExistingDatabase, + ViewCollection, +) +from documentdb_tests.framework.test_constants import ( + CAPPED_SIZE_LIMIT_BYTES, + DECIMAL128_HALF, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, +) + +# Property [cappedSize Numeric Type Acceptance]: cappedSize accepts any numeric type. +COLLMOD_CAPPED_SIZE_NUMERIC_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "size_numeric_int32", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedSize": 100_000}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept an int32 cappedSize", + ), + CommandTestCase( + "size_numeric_int64", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedSize": Int64(100_000)}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept an int64 cappedSize", + ), + CommandTestCase( + "size_numeric_double", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedSize": 100_000.0}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a double cappedSize", + ), + CommandTestCase( + "size_numeric_decimal", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedSize": Decimal128("100000")}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a decimal128 cappedSize", + ), +] + +# Property [cappedSize Null No-Op]: a null cappedSize is accepted as a no-op. +COLLMOD_CAPPED_SIZE_NULL_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "size_null", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedSize": None}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a null cappedSize as a no-op", + ), +] + +# Property [cappedSize Range Boundaries]: the inclusive lower and upper +# boundaries are both accepted. +COLLMOD_CAPPED_SIZE_BOUNDARY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "size_lower_boundary_1", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedSize": 1}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept the lower boundary cappedSize of 1", + ), + CommandTestCase( + "size_upper_boundary_max", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedSize": CAPPED_SIZE_LIMIT_BYTES}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept the upper boundary cappedSize of 2^50", + ), +] + +# Property [cappedSize Fractional Coercion]: a fractional cappedSize is coerced +# to an integer before the range check, with a double truncating toward zero and +# a decimal128 using banker's (round-half-to-even) rounding; a value that lands +# at or above the lower boundary is accepted. +COLLMOD_CAPPED_SIZE_FRACTIONAL_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "size_double_truncates_to_one", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedSize": 1.5}, + expected={"ok": Eq(1.0)}, + msg="collMod should truncate a fractional double cappedSize toward zero to the " + "lower boundary", + ), + CommandTestCase( + "size_decimal_bankers_rounds_up_to_one", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedSize": Decimal128("0.7")}, + expected={"ok": Eq(1.0)}, + msg="collMod should banker's-round a fractional decimal128 cappedSize up to the " + "lower boundary", + ), +] + +# Property [cappedSize Type Rejection]: any non-numeric cappedSize type produces +# a TypeMismatch error. +COLLMOD_CAPPED_SIZE_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"size_type_reject_{tid}", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx, v=val: {"collMod": ctx.collection, "cappedSize": v}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} cappedSize as a non-numeric type", + ) + for tid, val in [ + ("string", "8192"), + ("bool_true", True), + ("bool_false", False), + ("array", [8192]), + ("object", {"x": 1}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"hello")), + ("regex", Regex("abc", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [cappedSize Below Lower Bound]: a value below the inclusive lower +# bound after coercion produces a BadValue error, whether a double truncates +# toward zero or a decimal128 banker's-rounds down to zero. +COLLMOD_CAPPED_SIZE_LOW_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "size_low_zero", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedSize": 0}, + error_code=BAD_VALUE_ERROR, + msg="collMod should reject a cappedSize of 0 as below the lower bound", + ), + CommandTestCase( + "size_low_double_truncates_to_zero", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedSize": 0.7}, + error_code=BAD_VALUE_ERROR, + msg="collMod should reject a double cappedSize that truncates toward zero to 0 " + "as below the lower bound", + ), + CommandTestCase( + "size_low_decimal_bankers_rounds_to_zero", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedSize": DECIMAL128_HALF}, + error_code=BAD_VALUE_ERROR, + msg="collMod should reject a decimal128 cappedSize that banker's-rounds half-to-even " + "to 0 as below the lower bound", + ), + CommandTestCase( + "size_low_negative_fraction_truncates_to_zero", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedSize": -0.5}, + error_code=BAD_VALUE_ERROR, + msg="collMod should reject a negative fractional cappedSize that truncates toward zero " + "to 0 as below the lower bound", + ), + CommandTestCase( + "size_low_nan", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedSize": FLOAT_NAN}, + error_code=BAD_VALUE_ERROR, + msg="collMod should reject a NaN cappedSize, which coerces to 0, as below the lower bound", + ), + CommandTestCase( + "size_low_negative_infinity", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedSize": FLOAT_NEGATIVE_INFINITY}, + error_code=BAD_VALUE_ERROR, + msg="collMod should reject a -Infinity cappedSize, which coerces to INT64_MIN, " + "as below the lower bound", + ), +] + +# Property [cappedSize Above Upper Bound]: a value above the inclusive upper +# bound produces a BadValue error, including a decimal128 that banker's-rounds up +# over the bound and +Infinity which coerces above the bound. +COLLMOD_CAPPED_SIZE_HIGH_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "size_high_above_max", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedSize": CAPPED_SIZE_LIMIT_BYTES + 1}, + error_code=BAD_VALUE_ERROR, + msg="collMod should reject a cappedSize just above the 2^50 upper bound", + ), + CommandTestCase( + "size_high_decimal_bankers_rounds_over_bound", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "cappedSize": Decimal128(f"{CAPPED_SIZE_LIMIT_BYTES}.7"), + }, + error_code=BAD_VALUE_ERROR, + msg="collMod should reject a decimal128 cappedSize that banker's-rounds up over the " + "upper bound", + ), + CommandTestCase( + "size_high_positive_infinity", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedSize": FLOAT_INFINITY}, + error_code=BAD_VALUE_ERROR, + msg="collMod should reject a +Infinity cappedSize, which coerces to INT64_MAX, " + "as above the upper bound", + ), +] + +# Property [cappedSize Target Collection Restrictions]: cappedSize is rejected on +# any non-capped target: a regular or clustered collection produces an +# InvalidOptions error, a non-existent collection produces a NamespaceNotFound +# error, and the oplog produces an InvalidOptions error. +COLLMOD_CAPPED_SIZE_TARGET_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "size_target_regular", + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedSize": 100_000}, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject cappedSize on a regular collection", + ), + CommandTestCase( + "size_target_clustered", + target_collection=ClusteredCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedSize": 100_000}, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject cappedSize on a clustered collection", + ), + CommandTestCase( + "size_target_nonexistent", + docs=None, + command=lambda ctx: {"collMod": ctx.collection, "cappedSize": 100_000}, + error_code=NAMESPACE_NOT_FOUND_ERROR, + msg="collMod should reject cappedSize on a non-existent collection", + ), + CommandTestCase( + "size_target_oplog", + target_collection=ExistingDatabase(db_name="local"), + docs=None, + command=lambda ctx: {"collMod": "oplog.rs", "cappedSize": 100_000}, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject cappedSize on the oplog", + marks=(pytest.mark.requires(oplog=True),), + ), +] + +# Property [cappedSize View Crash]: applying cappedSize to a view must not crash +# the engine and must return a clean InvalidOptions error; the reference engine +# is skipped because it crashes (SIGSEGV) on this input. +COLLMOD_CAPPED_SIZE_VIEW_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "size_target_view", + target_collection=ViewCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedSize": 100_000}, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject cappedSize on a view with a clean error and not crash", + marks=( + pytest.mark.engine_xcrash( + engine="mongodb", + reason="Server crashes (SIGSEGV) when cappedSize is applied to a view", + ), + ), + ), +] + +COLLMOD_CAPPED_SIZE_TESTS: list[CommandTestCase] = ( + COLLMOD_CAPPED_SIZE_NUMERIC_TESTS + + COLLMOD_CAPPED_SIZE_NULL_TESTS + + COLLMOD_CAPPED_SIZE_BOUNDARY_TESTS + + COLLMOD_CAPPED_SIZE_FRACTIONAL_TESTS + + COLLMOD_CAPPED_SIZE_TYPE_ERROR_TESTS + + COLLMOD_CAPPED_SIZE_LOW_ERROR_TESTS + + COLLMOD_CAPPED_SIZE_HIGH_ERROR_TESTS + + COLLMOD_CAPPED_SIZE_TARGET_ERROR_TESTS + + COLLMOD_CAPPED_SIZE_VIEW_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_CAPPED_SIZE_TESTS)) +def test_collMod_capped_size(database_client, collection, test): + """Test collMod cappedSize acceptance and rejection on capped collections.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_change_streams.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_change_streams.py new file mode 100644 index 000000000..458434d03 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_change_streams.py @@ -0,0 +1,235 @@ +"""Tests for the collMod changeStreamPreAndPostImages option.""" + +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 ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + INVALID_OPTIONS_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 +from documentdb_tests.framework.property_checks import Eq +from documentdb_tests.framework.target_collection import ( + TimeseriesCollection, + ViewCollection, +) +from documentdb_tests.framework.test_constants import DECIMAL128_ONE_AND_HALF + +# Property [changeStreamPreAndPostImages Success]: a null value is accepted as +# an omitted field, and an object with a boolean enabled sub-field is accepted +# for either truth value on a regular collection. +COLLMOD_CHANGE_STREAM_SUCCESS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "null", + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "changeStreamPreAndPostImages": None, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a null changeStreamPreAndPostImages as an omitted field", + ), + CommandTestCase( + "enabled_true", + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "changeStreamPreAndPostImages": {"enabled": True}, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept changeStreamPreAndPostImages with enabled true", + ), + CommandTestCase( + "enabled_false", + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "changeStreamPreAndPostImages": {"enabled": False}, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept changeStreamPreAndPostImages with enabled false", + ), +] + +# Property [changeStreamPreAndPostImages Top-Level Type Rejection]: a +# changeStreamPreAndPostImages value that is neither an object nor null produces +# a TypeMismatch error. +COLLMOD_CHANGE_STREAM_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"toplevel_type_{tid}", + docs=[], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "changeStreamPreAndPostImages": v, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} changeStreamPreAndPostImages as a non-object", + ) + for tid, val in [ + ("string", "x"), + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool_true", True), + ("bool_false", False), + ("array", [{"enabled": 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()), + ] +] + +# Property [changeStreamPreAndPostImages Missing enabled]: a +# changeStreamPreAndPostImages object whose enabled sub-field is absent or null +# produces a FailedToParse error because enabled is required and a null value is +# treated as missing rather than as a type mismatch. +COLLMOD_CHANGE_STREAM_MISSING_ENABLED_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "missing_enabled", + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "changeStreamPreAndPostImages": {}, + }, + error_code=MISSING_FIELD_ERROR, + msg="collMod should reject changeStreamPreAndPostImages with no enabled sub-field", + ), + CommandTestCase( + "enabled_null", + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "changeStreamPreAndPostImages": {"enabled": None}, + }, + error_code=MISSING_FIELD_ERROR, + msg="collMod should treat a null enabled as a missing required sub-field", + ), +] + +# Property [changeStreamPreAndPostImages Unknown Sub-Field]: a +# changeStreamPreAndPostImages object containing an unrecognized sub-field +# produces an UnknownField error, and that error fires even when the required +# enabled sub-field is also present. +COLLMOD_CHANGE_STREAM_UNKNOWN_FIELD_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "unknown_field_only", + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "changeStreamPreAndPostImages": {"bogus": 1}, + }, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="collMod should reject an unknown changeStreamPreAndPostImages sub-field", + ), + CommandTestCase( + "unknown_field_with_enabled", + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "changeStreamPreAndPostImages": {"bogus": 1, "enabled": True}, + }, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="collMod should reject an unknown sub-field before the missing-enabled check", + ), +] + +# Property [changeStreamPreAndPostImages enabled Type Rejection]: an enabled +# sub-field whose value is neither a boolean nor absent produces a TypeMismatch +# error. +COLLMOD_CHANGE_STREAM_ENABLED_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"enabled_type_{tid}", + docs=[], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "changeStreamPreAndPostImages": {"enabled": v}, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} enabled value as a non-boolean", + ) + for tid, val in [ + ("string", "true"), + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.0), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("array", [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 [changeStreamPreAndPostImages Unsupported Collection Type]: applying +# changeStreamPreAndPostImages to a view or a time series collection produces an +# InvalidOptions error regardless of the enabled truth value. +COLLMOD_CHANGE_STREAM_UNSUPPORTED_TARGET_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"{target_id}_enabled_{enabled_id}", + docs=[], + target_collection=target, + command=lambda ctx, e=enabled: { + "collMod": ctx.collection, + "changeStreamPreAndPostImages": {"enabled": e}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg=f"collMod should reject changeStreamPreAndPostImages on a {target_id}", + ) + for target_id, target in [ + ("view", ViewCollection()), + ("timeseries", TimeseriesCollection()), + ] + for enabled_id, enabled in [("true", True), ("false", False)] +] + +COLLMOD_CHANGE_STREAM_ERROR_TESTS: list[CommandTestCase] = ( + COLLMOD_CHANGE_STREAM_TYPE_ERROR_TESTS + + COLLMOD_CHANGE_STREAM_MISSING_ENABLED_ERROR_TESTS + + COLLMOD_CHANGE_STREAM_UNKNOWN_FIELD_ERROR_TESTS + + COLLMOD_CHANGE_STREAM_ENABLED_TYPE_ERROR_TESTS + + COLLMOD_CHANGE_STREAM_UNSUPPORTED_TARGET_ERROR_TESTS +) + +COLLMOD_CHANGE_STREAM_ALL_TESTS: list[CommandTestCase] = ( + COLLMOD_CHANGE_STREAM_SUCCESS_TESTS + COLLMOD_CHANGE_STREAM_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_CHANGE_STREAM_ALL_TESTS)) +def test_collMod_change_streams(database_client, collection, test): + """Test collMod changeStreamPreAndPostImages option behavior.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_comment.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_comment.py new file mode 100644 index 000000000..3401ba6c4 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_comment.py @@ -0,0 +1,91 @@ +"""Tests for the collMod comment option.""" + +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 ( + 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_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 + +# Property [comment Type Acceptance]: a comment of any BSON type representable +# by pymongo is accepted without changing the command result and is never echoed +# back in the response. +COLLMOD_COMMENT_TYPE_ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"type_{tid}", + docs=[], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "comment": v, + }, + expected={"ok": Eq(1.0), "comment": NotExists()}, + msg=f"collMod should accept a {tid} comment without echoing it", + ) + for tid, val in [ + ("string", "a note"), + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool_true", True), + ("bool_false", False), + ("null", None), + ("array", [1, "two", {"three": 3}]), + ("object", {"reason": "audit"}), + ("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 [comment Does Not Suppress Errors]: a comment paired with an +# otherwise-invalid option does not suppress the option's error. +COLLMOD_COMMENT_NO_SUPPRESS_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "no_suppress_index_type_error", + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "comment": "a note", + "index": "not_an_object", + }, + error_code=TYPE_MISMATCH_ERROR, + msg="collMod should still reject an invalid index when a comment is present", + ), +] + +COLLMOD_COMMENT_ALL_TESTS: list[CommandTestCase] = ( + COLLMOD_COMMENT_TYPE_ACCEPTANCE_TESTS + COLLMOD_COMMENT_NO_SUPPRESS_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_COMMENT_ALL_TESTS)) +def test_collMod_comment(database_client, collection, test): + """Test collMod comment option behavior.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_dry_run.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_dry_run.py new file mode 100644 index 000000000..332359917 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_dry_run.py @@ -0,0 +1,278 @@ +"""Tests for collMod dryRun behavior.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp +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.error_codes import ( + CANNOT_CONVERT_INDEX_TO_UNIQUE_ERROR, + INVALID_OPTIONS_ERROR, + 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, NotExists +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ZERO, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + INT64_ZERO, +) + +# Property [dryRun Truthy Checks Without Converting]: a bool true or any numeric +# type that coerces to true (any nonzero value, including negatives, NaN, and +# Infinity) runs the unique conversion as a dry run, validating without +# converting, so the result omits the unique_new echo that an actual conversion +# would produce. +COLLMOD_DRY_RUN_TRUTHY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"truthy_{tid}", + indexes=[IndexModel([("a", 1)], name="a_1", prepareUnique=True)], + docs=[{"_id": 1, "a": 1}, {"_id": 2, "a": 2}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "index": {"name": "a_1", "unique": True}, + "dryRun": v, + }, + expected={"ok": Eq(1.0), "unique_new": NotExists(), "unique_old": NotExists()}, + msg=f"collMod should treat a {tid} dryRun as a dry run that checks without converting", + ) + for tid, val in [ + ("bool_true", True), + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("decimal128", Decimal128("1")), + ("int32_negative", -1), + ("double_negative", -2.0), + ("decimal128_negative", Decimal128("-1")), + ("float_nan", FLOAT_NAN), + ("decimal128_nan", DECIMAL128_NAN), + ("float_infinity", FLOAT_INFINITY), + ("decimal128_infinity", DECIMAL128_INFINITY), + ("float_negative_infinity", FLOAT_NEGATIVE_INFINITY), + ("decimal128_negative_infinity", DECIMAL128_NEGATIVE_INFINITY), + ] +] + +# Property [dryRun Falsy Performs Conversion]: a bool false, any numeric zero +# (including negative zero), or null disables dry run, so the unique conversion +# is actually performed and the result echoes unique_new true. +COLLMOD_DRY_RUN_FALSY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"falsy_{tid}", + indexes=[IndexModel([("a", 1)], name="a_1", prepareUnique=True)], + docs=[{"_id": 1, "a": 1}, {"_id": 2, "a": 2}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "index": {"name": "a_1", "unique": True}, + "dryRun": v, + }, + expected={"ok": Eq(1.0), "unique_new": Eq(True)}, + msg=f"collMod should treat a {tid} dryRun as disabled and perform the conversion", + ) + for tid, val in [ + ("bool_false", False), + ("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), + ("null", None), + ] +] + +# Property [dryRun Falsy Accepted Without A Unique Conversion]: a falsy dryRun +# (bool false, numeric zero, or null) is accepted with ok:1.0 even when no unique +# conversion is present (whether paired with a non-unique modification such as +# hidden, which still applies, or with no index modification at all), unlike a +# truthy dryRun, which requires a unique conversion. +COLLMOD_DRY_RUN_FALSY_NO_CONVERSION_TESTS: list[CommandTestCase] = [ + *[ + CommandTestCase( + f"falsy_no_conversion_hidden_{tid}", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}, {"_id": 2, "a": 2}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "index": {"name": "a_1", "hidden": True}, + "dryRun": v, + }, + expected={"ok": Eq(1.0), "hidden_old": Eq(False), "hidden_new": Eq(True)}, + msg=f"collMod should accept a {tid} dryRun on a non-unique modification and apply it", + ) + for tid, val in [ + ("bool_false", False), + ("int32_zero", 0), + ("null", None), + ] + ], + *[ + CommandTestCase( + f"falsy_no_conversion_no_index_{tid}", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}, {"_id": 2, "a": 2}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "dryRun": v, + }, + expected={"ok": Eq(1.0)}, + msg=f"collMod should accept a {tid} dryRun with no index modification present", + ) + for tid, val in [ + ("bool_false", False), + ("int32_zero", 0), + ] + ], +] + +COLLMOD_DRY_RUN_SUCCESS_TESTS: list[CommandTestCase] = ( + COLLMOD_DRY_RUN_TRUTHY_TESTS + + COLLMOD_DRY_RUN_FALSY_TESTS + + COLLMOD_DRY_RUN_FALSY_NO_CONVERSION_TESTS +) + +# Property [dryRun Type Rejection]: a dryRun value that is neither a bool nor a +# numeric type is rejected as a type mismatch, since only bool and numeric +# values can be coerced to the dry-run flag. +COLLMOD_DRY_RUN_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"type_{tid}", + indexes=[IndexModel([("a", 1)], name="a_1", prepareUnique=True)], + docs=[{"_id": 1, "a": 1}, {"_id": 2, "a": 2}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "index": {"name": "a_1", "unique": True}, + "dryRun": v, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} dryRun value as a type mismatch", + ) + for tid, val in [ + ("string", "yes"), + ("array", [True]), + ("object", {"x": 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 [dryRun Truthy Requires A Unique Conversion]: a truthy dryRun with no +# index modification, or with an index modification that is not a unique +# conversion (an identify-only index or a non-unique change such as hidden), is +# rejected as an invalid option because dry run mode validates a pending unique +# conversion and has nothing to check otherwise. +COLLMOD_DRY_RUN_REQUIRES_CONVERSION_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "no_index_modification", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}, {"_id": 2, "a": 2}], + command=lambda ctx: {"collMod": ctx.collection, "dryRun": True}, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject a truthy dryRun with no index modification as an " + "invalid option", + ), + CommandTestCase( + "identify_only_index", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}, {"_id": 2, "a": 2}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1"}, + "dryRun": True, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject a truthy dryRun with an identify-only index as an " + "invalid option", + ), + CommandTestCase( + "non_unique_modification", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}, {"_id": 2, "a": 2}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "hidden": True}, + "dryRun": True, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject a truthy dryRun with a non-unique index " + "modification as an invalid option", + ), +] + +# Property [dryRun Truthy Reports Conversion Violations]: a truthy dryRun on a +# unique conversion of an index whose documents contain duplicate values is +# rejected because the conversion would violate uniqueness, reported without +# converting. +COLLMOD_DRY_RUN_DUPLICATE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "unique_conversion_with_duplicates", + indexes=[IndexModel([("a", 1)], name="a_1")], + # Documents that share a value on the indexed field so a unique + # conversion has a violation to report. + docs=[{"_id": 1, "a": 1}, {"_id": 2, "a": 1}], + setup=lambda coll: execute_command( + coll, + {"collMod": coll.name, "index": {"name": "a_1", "prepareUnique": True}}, + ), + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "unique": True}, + "dryRun": True, + }, + error_code=CANNOT_CONVERT_INDEX_TO_UNIQUE_ERROR, + msg="collMod should reject a dry-run unique conversion that has duplicate " + "values without converting", + ), +] + +COLLMOD_DRY_RUN_ERROR_TESTS: list[CommandTestCase] = ( + COLLMOD_DRY_RUN_TYPE_ERROR_TESTS + + COLLMOD_DRY_RUN_REQUIRES_CONVERSION_ERROR_TESTS + + COLLMOD_DRY_RUN_DUPLICATE_ERROR_TESTS +) + +COLLMOD_DRY_RUN_TESTS: list[CommandTestCase] = ( + COLLMOD_DRY_RUN_SUCCESS_TESTS + COLLMOD_DRY_RUN_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_DRY_RUN_TESTS)) +def test_collMod_dry_run(database_client, collection, test): + """Test collMod dryRun behavior.""" + collection = test.prepare(database_client, collection) + if test.setup: + test.setup(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/core/collections/commands/collMod/test_collMod_index_argument.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_index_argument.py new file mode 100644 index 000000000..7e4818c7b --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_index_argument.py @@ -0,0 +1,155 @@ +"""Tests for collMod index argument presence, type, structure, and applicability.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) +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.error_codes import ( + 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 +from documentdb_tests.framework.property_checks import Eq, NotExists +from documentdb_tests.framework.target_collection import ViewCollection + +# Property [Index Null No-Op]: an index value of null is accepted and treated +# as an omitted field, yielding a no-op success with no index modification +# echoed in the result. +COLLMOD_INDEX_NULL_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "null_no_op", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "index": None}, + expected={ + "ok": Eq(1.0), + "expireAfterSeconds_new": NotExists(), + "expireAfterSeconds_old": NotExists(), + "hidden_new": NotExists(), + "hidden_old": NotExists(), + "prepareUnique_new": NotExists(), + "prepareUnique_old": NotExists(), + "unique_new": NotExists(), + "unique_old": NotExists(), + }, + msg="collMod should accept a null index as an omitted no-op", + ), +] + +# Property [Index Non-Document Type Rejection]: a non-document, non-null value +# for index produces a type-mismatch error. +COLLMOD_INDEX_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"type_{tid}", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=val: {"collMod": ctx.collection, "index": v}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} index value as a type mismatch", + ) + for tid, val in [ + ("string", "a_1"), + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("decimal128", Decimal128("1")), + ("bool_true", True), + ("bool_false", False), + ("array", [{"name": "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 [Empty Index Document Rejection]: an empty index document specifies +# neither an index name nor a key pattern and is rejected as an invalid option. +COLLMOD_INDEX_EMPTY_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "empty_document", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "index": {}}, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject an empty index document as an invalid option", + ), +] + +# Property [Unknown Index Sub-Field Rejection]: an unrecognized sub-field inside +# the index document is rejected as an unrecognized field. +COLLMOD_INDEX_UNKNOWN_FIELD_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "unknown_subfield", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "index": {"name": "a_1", "bogus": 1}}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="collMod should reject an unknown index sub-field as an unrecognized field", + ), +] + +# Property [Index Unsupported Collection Type Rejection]: an index modification +# applied to a view is rejected as an invalid option, since the index option is +# not supported on a view. +COLLMOD_INDEX_VIEW_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "view_index_not_supported", + target_collection=ViewCollection(), + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "hidden": True}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject an index modification on a view as an invalid option", + ), +] + +COLLMOD_INDEX_ARGUMENT_TESTS: list[CommandTestCase] = ( + COLLMOD_INDEX_NULL_TESTS + + COLLMOD_INDEX_TYPE_ERROR_TESTS + + COLLMOD_INDEX_EMPTY_ERROR_TESTS + + COLLMOD_INDEX_UNKNOWN_FIELD_ERROR_TESTS + + COLLMOD_INDEX_VIEW_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_INDEX_ARGUMENT_TESTS)) +def test_collMod_index_argument(database_client, collection, test): + """Test collMod index argument acceptance and structural rejection.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_index_expire.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_index_expire.py new file mode 100644 index 000000000..218ea1881 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_index_expire.py @@ -0,0 +1,418 @@ +"""Tests for collMod index expireAfterSeconds.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) +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.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 +from documentdb_tests.framework.property_checks import Eq, NotExists +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_INT64_OVERFLOW, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + INT32_MAX, + INT32_OVERFLOW, + INT64_ZERO, +) + +# Property [Index expireAfterSeconds Numeric Coercion]: setting +# index.expireAfterSeconds on an existing TTL index accepts any numeric type and +# coerces it to the new TTL, with a double truncating toward zero and a +# decimal128 using banker's (round-half-to-even) rounding, echoing the prior TTL +# as expireAfterSeconds_old and the coerced value as expireAfterSeconds_new. +COLLMOD_INDEX_EXPIRE_COERCION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"expire_coerce_{tid}", + indexes=[IndexModel([("a", 1)], name="a_ttl", expireAfterSeconds=100)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "index": {"name": "a_ttl", "expireAfterSeconds": v}, + }, + expected={ + "ok": Eq(1.0), + "expireAfterSeconds_old": Eq(Int64(100)), + "expireAfterSeconds_new": Eq(Int64(expected_new)), + }, + msg=f"collMod should accept a {tid} expireAfterSeconds and coerce it to {expected_new}", + ) + for tid, val, expected_new in [ + ("int32", 50, 50), + ("int64", Int64(50), 50), + ("double_exact", 50.0, 50), + ("double_truncates_toward_zero", 50.7, 50), + ("decimal128_rounds_down", Decimal128("50.4"), 50), + ("decimal128_half_to_even_down", Decimal128("50.5"), 50), + ("decimal128_rounds_up", Decimal128("50.7"), 51), + ("decimal128_half_to_even_up", Decimal128("51.5"), 52), + ] +] + +# Property [Index expireAfterSeconds Boundaries]: the lower bound and the upper +# bound are accepted, and a negative value that truncates toward zero to the +# lower bound is accepted. +COLLMOD_INDEX_EXPIRE_BOUNDARY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"expire_boundary_{tid}", + indexes=[IndexModel([("a", 1)], name="a_ttl", expireAfterSeconds=100)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "index": {"name": "a_ttl", "expireAfterSeconds": v}, + }, + expected={ + "ok": Eq(1.0), + "expireAfterSeconds_old": Eq(Int64(100)), + "expireAfterSeconds_new": Eq(Int64(expected_new)), + }, + msg=f"collMod should accept a {tid} expireAfterSeconds as {expected_new}", + ) + for tid, val, expected_new in [ + ("zero", 0, 0), + ("int32_max", INT32_MAX, INT32_MAX), + ("negative_fraction_truncates_to_zero", -0.9, 0), + ] +] + +# Property [Index expireAfterSeconds Clamp]: a value exceeding int32 max +# (an int64 above int32 max, a double or decimal128 overflow, and positive +# Infinity) is silently clamped to int32 max with no overflow error. +COLLMOD_INDEX_EXPIRE_CLAMP_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"expire_clamp_{tid}", + indexes=[IndexModel([("a", 1)], name="a_ttl", expireAfterSeconds=100)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "index": {"name": "a_ttl", "expireAfterSeconds": v}, + }, + expected={ + "ok": Eq(1.0), + "expireAfterSeconds_old": Eq(Int64(100)), + "expireAfterSeconds_new": Eq(Int64(INT32_MAX)), + }, + msg=f"collMod should clamp a {tid} expireAfterSeconds to 2147483647", + ) + for tid, val in [ + ("int64_above_int32_max", Int64(INT32_OVERFLOW)), + ("double_above_int32_max", float(INT32_OVERFLOW)), + ("decimal128_overflow", DECIMAL128_INT64_OVERFLOW), + ("float_infinity", FLOAT_INFINITY), + ("decimal128_infinity", DECIMAL128_INFINITY), + ] +] + +# Property [Index expireAfterSeconds NaN]: a NaN value (float or decimal128) is +# coerced to 0. +COLLMOD_INDEX_EXPIRE_NAN_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"expire_nan_{tid}", + indexes=[IndexModel([("a", 1)], name="a_ttl", expireAfterSeconds=100)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "index": {"name": "a_ttl", "expireAfterSeconds": v}, + }, + expected={ + "ok": Eq(1.0), + "expireAfterSeconds_old": Eq(Int64(100)), + "expireAfterSeconds_new": Eq(INT64_ZERO), + }, + msg=f"collMod should coerce a {tid} NaN expireAfterSeconds to 0", + ) + for tid, val in [ + ("float", FLOAT_NAN), + ("decimal128", DECIMAL128_NAN), + ] +] + +# Property [Index expireAfterSeconds Same Value]: setting expireAfterSeconds to +# the same value on an existing TTL index echoes both expireAfterSeconds_old and +# expireAfterSeconds_new. +COLLMOD_INDEX_EXPIRE_SAME_VALUE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "expire_same_value", + indexes=[IndexModel([("a", 1)], name="a_ttl", expireAfterSeconds=100)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_ttl", "expireAfterSeconds": 100}, + }, + expected={ + "ok": Eq(1.0), + "expireAfterSeconds_old": Eq(Int64(100)), + "expireAfterSeconds_new": Eq(Int64(100)), + }, + msg="collMod should echo both old and new TTL when the value is unchanged", + ), +] + +# Property [Index expireAfterSeconds Converts Non-TTL]: setting +# expireAfterSeconds on a single-field non-TTL index converts it to a TTL index, +# echoing expireAfterSeconds_new with no expireAfterSeconds_old. +COLLMOD_INDEX_EXPIRE_CONVERT_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "expire_converts_non_ttl", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "expireAfterSeconds": 50}, + }, + expected={ + "ok": Eq(1.0), + "expireAfterSeconds_old": NotExists(), + "expireAfterSeconds_new": Eq(Int64(50)), + }, + msg="collMod should convert a non-TTL index to TTL with new value and no old value", + ), +] + +# Property [Index Field Combination expireAfterSeconds And hidden]: setting +# expireAfterSeconds and hidden in one index document applies both, echoing the +# new TTL as expireAfterSeconds_new and the hidden change as hidden_old and +# hidden_new. +COLLMOD_INDEX_COMBO_EXPIRE_HIDDEN_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "combo_expire_and_hidden", + indexes=[IndexModel([("a", 1)], name="a_ttl", expireAfterSeconds=100, hidden=False)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_ttl", "expireAfterSeconds": 200, "hidden": True}, + }, + expected={ + "ok": Eq(1.0), + "expireAfterSeconds_old": Eq(Int64(100)), + "expireAfterSeconds_new": Eq(Int64(200)), + "hidden_old": Eq(False), + "hidden_new": Eq(True), + }, + msg="collMod should apply both expireAfterSeconds and hidden in one index document", + ), +] + +# Property [Index expireAfterSeconds Non-Numeric Rejection]: a bool value and +# every other non-numeric type for index.expireAfterSeconds produce a +# type-mismatch error (bool is not treated as numeric here). +COLLMOD_INDEX_EXPIRE_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"expire_type_{tid}", + indexes=[IndexModel([("a", 1)], name="a_ttl", expireAfterSeconds=100)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "index": {"name": "a_ttl", "expireAfterSeconds": v}, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} expireAfterSeconds as a type mismatch", + ) + for tid, val in [ + ("string", "100"), + ("bool_true", True), + ("bool_false", False), + ("array", [100]), + ("object", {"x": 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 expireAfterSeconds Null Rejection]: a null +# index.expireAfterSeconds leaves no modification field and is rejected as an +# invalid option. +COLLMOD_INDEX_EXPIRE_NULL_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "expire_null", + indexes=[IndexModel([("a", 1)], name="a_ttl", expireAfterSeconds=100)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_ttl", "expireAfterSeconds": None}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject a null expireAfterSeconds as leaving no modification field", + ), +] + +# Property [Index expireAfterSeconds Negative Rejection]: a value that truncates +# toward zero to a negative number is rejected as an invalid option, including +# -Infinity, which is rejected as below zero rather than clamped like +Infinity. +COLLMOD_INDEX_EXPIRE_NEGATIVE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"expire_negative_{tid}", + indexes=[IndexModel([("a", 1)], name="a_ttl", expireAfterSeconds=100)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "index": {"name": "a_ttl", "expireAfterSeconds": v}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg=f"collMod should reject a {tid} expireAfterSeconds that truncates to a negative value", + ) + for tid, val in [ + ("int32", -1), + ("int64", Int64(-1)), + ("double", -1.5), + ("decimal128", Decimal128("-1")), + ("float_negative_infinity", FLOAT_NEGATIVE_INFINITY), + ("decimal128_negative_infinity", DECIMAL128_NEGATIVE_INFINITY), + ] +] + +# Property [Index expireAfterSeconds Converts Special Single-Field Index Types]: +# setting expireAfterSeconds on a single-field hashed or geospatial (2dsphere) +# index converts it to a TTL index, echoing expireAfterSeconds_new with no +# expireAfterSeconds_old, because TTL eligibility is decided by the key shape +# (single-field non-_id), not the index sub-type. +COLLMOD_INDEX_EXPIRE_SPECIAL_TYPE_CONVERT_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"expire_converts_{tid}", + indexes=[IndexModel(key, name=name)], + docs=[], + command=lambda ctx, n=name: { + "collMod": ctx.collection, + "index": {"name": n, "expireAfterSeconds": 100}, + }, + expected={ + "ok": Eq(1.0), + "expireAfterSeconds_old": NotExists(), + "expireAfterSeconds_new": Eq(Int64(100)), + }, + msg=f"collMod should convert a single-field {tid} index to TTL with a new value " + "and no old value", + ) + for tid, key, name in [ + ("hashed", [("a", "hashed")], "a_hashed"), + ("2dsphere", [("loc", "2dsphere")], "loc_2dsphere"), + ] +] + +# Property [Index expireAfterSeconds Non-Single-Field Rejection]: applying +# expireAfterSeconds to certain index types is rejected as an invalid option, +# since TTL is supported only on single-field non-_id indexes. +COLLMOD_INDEX_EXPIRE_NON_SINGLE_FIELD_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "expire_compound_index", + indexes=[IndexModel([("a", 1), ("b", 1)], name="ab_1")], + docs=[{"_id": 1, "a": 1, "b": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "ab_1", "expireAfterSeconds": 100}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject expireAfterSeconds on a compound index as an invalid option", + ), + CommandTestCase( + "expire_id_index", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "_id_", "expireAfterSeconds": 100}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject expireAfterSeconds on the _id_ index as an invalid option", + ), + CommandTestCase( + "expire_text_index", + indexes=[IndexModel([("a", "text")], name="a_text")], + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_text", "expireAfterSeconds": 100}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject expireAfterSeconds on a text index as an invalid option", + ), +] + +# Property [Index expireAfterSeconds Wildcard Crash]: applying expireAfterSeconds +# to a wildcard index must not crash the engine and must return a clean +# InvalidOptions error, since a wildcard index is not a valid TTL target. +COLLMOD_INDEX_EXPIRE_WILDCARD_CRASH_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "expire_wildcard_index", + indexes=[IndexModel([("$**", 1)], name="wild")], + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "wild", "expireAfterSeconds": 100}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject expireAfterSeconds on a wildcard index with a clean error " + "and not crash", + marks=( + pytest.mark.engine_xcrash( + engine="mongodb", + reason="Server crashes when expireAfterSeconds is applied to a wildcard index", + ), + ), + ), +] + +COLLMOD_INDEX_EXPIRE_TESTS: list[CommandTestCase] = ( + COLLMOD_INDEX_EXPIRE_COERCION_TESTS + + COLLMOD_INDEX_EXPIRE_BOUNDARY_TESTS + + COLLMOD_INDEX_EXPIRE_CLAMP_TESTS + + COLLMOD_INDEX_EXPIRE_NAN_TESTS + + COLLMOD_INDEX_EXPIRE_SAME_VALUE_TESTS + + COLLMOD_INDEX_EXPIRE_CONVERT_TESTS + + COLLMOD_INDEX_EXPIRE_SPECIAL_TYPE_CONVERT_TESTS + + COLLMOD_INDEX_COMBO_EXPIRE_HIDDEN_TESTS + + COLLMOD_INDEX_EXPIRE_TYPE_ERROR_TESTS + + COLLMOD_INDEX_EXPIRE_NULL_ERROR_TESTS + + COLLMOD_INDEX_EXPIRE_NEGATIVE_ERROR_TESTS + + COLLMOD_INDEX_EXPIRE_NON_SINGLE_FIELD_ERROR_TESTS + + COLLMOD_INDEX_EXPIRE_WILDCARD_CRASH_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_INDEX_EXPIRE_TESTS)) +def test_collMod_index_expire(database_client, collection, test): + """Test collMod index expireAfterSeconds acceptance and rejection.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_index_hidden.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_index_hidden.py new file mode 100644 index 000000000..e212f5430 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_index_hidden.py @@ -0,0 +1,274 @@ +"""Tests for collMod index hidden.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) +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.error_codes import ( + BAD_VALUE_ERROR, + INDEX_NOT_FOUND_ERROR, + INVALID_OPTIONS_ERROR, + 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, NotExists +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ZERO, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + INT64_ZERO, +) + +# Property [Index hidden Truthy Coercion]: a bool true or any numeric type that +# coerces to true (any nonzero value, including negatives, NaN, and Infinity) +# sets hidden on a previously unhidden index, echoing hidden_old false and +# hidden_new true, despite the docs describing the field as boolean-only. +COLLMOD_INDEX_HIDDEN_TRUTHY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"hidden_truthy_{tid}", + indexes=[IndexModel([("a", 1)], name="a_1", hidden=False)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "index": {"name": "a_1", "hidden": v}, + }, + expected={"ok": Eq(1.0), "hidden_old": Eq(False), "hidden_new": Eq(True)}, + msg=f"collMod should coerce a {tid} hidden value to true and echo the change", + ) + for tid, val in [ + ("bool_true", True), + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("decimal128", Decimal128("1")), + ("int32_negative", -1), + ("double_negative", -2.0), + ("decimal128_negative", Decimal128("-1")), + ("float_nan", FLOAT_NAN), + ("decimal128_nan", DECIMAL128_NAN), + ("float_infinity", FLOAT_INFINITY), + ("decimal128_infinity", DECIMAL128_INFINITY), + ("float_negative_infinity", FLOAT_NEGATIVE_INFINITY), + ("decimal128_negative_infinity", DECIMAL128_NEGATIVE_INFINITY), + ] +] + +# Property [Index hidden Falsy Coercion]: any numeric zero (including negative +# zero) coerces to false, so applying it to an already unhidden index does not +# change the state and the result omits hidden_old and hidden_new. +COLLMOD_INDEX_HIDDEN_FALSY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"hidden_falsy_{tid}", + indexes=[IndexModel([("a", 1)], name="a_1", hidden=False)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "index": {"name": "a_1", "hidden": v}, + }, + expected={ + "ok": Eq(1.0), + "hidden_old": NotExists(), + "hidden_new": NotExists(), + }, + msg=f"collMod should coerce a {tid} hidden value to false, leaving the state unchanged", + ) + for tid, val in [ + ("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), + ] +] + +# Property [Index hidden Unchanged State]: setting hidden to its current value +# (hiding an already-hidden index or unhiding an already-unhidden index) does +# not change the state, so the result omits hidden_old and hidden_new, while a +# genuine state change echoes both. +COLLMOD_INDEX_HIDDEN_UNCHANGED_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "hidden_already_hidden", + indexes=[IndexModel([("a", 1)], name="a_1", hidden=True)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "hidden": True}, + }, + expected={ + "ok": Eq(1.0), + "hidden_old": NotExists(), + "hidden_new": NotExists(), + }, + msg="collMod should omit hidden_old and hidden_new when hiding an already-hidden index", + ), + CommandTestCase( + "hidden_already_unhidden", + indexes=[IndexModel([("a", 1)], name="a_1", hidden=False)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "hidden": False}, + }, + expected={ + "ok": Eq(1.0), + "hidden_old": NotExists(), + "hidden_new": NotExists(), + }, + msg="collMod should omit hidden_old and hidden_new when unhiding an already-unhidden " + "index", + ), + CommandTestCase( + "hidden_unhide_changes", + indexes=[IndexModel([("a", 1)], name="a_1", hidden=True)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "hidden": False}, + }, + expected={"ok": Eq(1.0), "hidden_old": Eq(True), "hidden_new": Eq(False)}, + msg="collMod should echo the change when unhiding a hidden index", + ), +] + +# Property [Index Id Index Hidden Rejection]: identifying the _id_ index by name +# or keyPattern for a hidden change is rejected as a bad value. +COLLMOD_INDEX_ID_HIDDEN_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "id_index_hidden_by_name", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "_id_", "hidden": True}, + }, + error_code=BAD_VALUE_ERROR, + msg="collMod should reject hiding the _id_ index identified by name as a bad value", + ), + CommandTestCase( + "id_index_hidden_by_key_pattern", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"keyPattern": {"_id": 1}, "hidden": True}, + }, + error_code=BAD_VALUE_ERROR, + msg="collMod should reject hiding the _id_ index identified by keyPattern as a bad value", + ), +] + +# Property [Index hidden Non-Bool-Non-Numeric Rejection]: a type outside bool +# and the numeric types for index.hidden produces a type-mismatch error. +COLLMOD_INDEX_HIDDEN_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"hidden_type_{tid}", + indexes=[IndexModel([("a", 1)], name="a_1", hidden=False)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "index": {"name": "a_1", "hidden": v}, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} hidden value as a type mismatch", + ) + for tid, val in [ + ("string", "true"), + ("array", [True]), + ("object", {"x": 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 hidden Null Rejection]: a null index.hidden is treated as +# absent, leaving no modification field, and is rejected as an invalid option. +COLLMOD_INDEX_HIDDEN_NULL_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "hidden_null", + indexes=[IndexModel([("a", 1)], name="a_1", hidden=False)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "hidden": None}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should treat a null hidden value as absent and reject it as an invalid option", + ), +] + +# Property [Index hidden Text Index Identification]: a text index can be hidden +# by name but not by keyPattern, because a text index stores a different stored +# key pattern, so identifying it by keyPattern is index-not-found. +COLLMOD_INDEX_HIDDEN_TEXT_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "hidden_text_index_by_key_pattern", + indexes=[IndexModel([("a", "text")], name="a_text")], + docs=[{"_id": 1, "a": "hello world"}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"keyPattern": {"a": "text"}, "hidden": True}, + }, + error_code=INDEX_NOT_FOUND_ERROR, + msg="collMod should reject hiding a text index by keyPattern as index-not-found", + ), +] + +COLLMOD_INDEX_HIDDEN_TESTS: list[CommandTestCase] = ( + COLLMOD_INDEX_HIDDEN_TRUTHY_TESTS + + COLLMOD_INDEX_HIDDEN_FALSY_TESTS + + COLLMOD_INDEX_HIDDEN_UNCHANGED_TESTS + + COLLMOD_INDEX_ID_HIDDEN_ERROR_TESTS + + COLLMOD_INDEX_HIDDEN_TYPE_ERROR_TESTS + + COLLMOD_INDEX_HIDDEN_NULL_ERROR_TESTS + + COLLMOD_INDEX_HIDDEN_TEXT_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_INDEX_HIDDEN_TESTS)) +def test_collMod_index_hidden(database_client, collection, test): + """Test collMod index hidden acceptance and rejection.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_index_identifier.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_index_identifier.py new file mode 100644 index 000000000..f1c9abb11 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_index_identifier.py @@ -0,0 +1,321 @@ +"""Tests for collMod index identifier resolution by name and keyPattern.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) +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.error_codes import ( + INDEX_NOT_FOUND_ERROR, + INVALID_OPTIONS_ERROR, + 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 + +# Property [Index Identifier Resolution]: an index identifier given by name or +# by key pattern resolves to the matching existing index, so a paired +# modification applies and its old/new values are echoed in the result. +COLLMOD_INDEX_RESOLUTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "resolution_by_name", + indexes=[IndexModel([("a", 1)], name="a_ttl", expireAfterSeconds=100)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_ttl", "expireAfterSeconds": 200}, + }, + expected={ + "ok": Eq(1.0), + "expireAfterSeconds_old": Eq(Int64(100)), + "expireAfterSeconds_new": Eq(Int64(200)), + }, + msg="collMod should resolve an index by its name", + ), + CommandTestCase( + "resolution_by_key_pattern", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"keyPattern": {"a": 1}, "hidden": True}, + }, + expected={"ok": Eq(1.0), "hidden_old": Eq(False), "hidden_new": Eq(True)}, + msg="collMod should resolve an index by its key pattern", + ), +] + +# Property [Index Identifier Ambiguity Rejection]: supplying both name and +# keyPattern, or neither, fails to unambiguously identify an index and is +# rejected as an invalid option, even when both refer to the same index. +COLLMOD_INDEX_IDENTIFIER_AMBIGUITY_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "identifier_both_name_and_key_pattern", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "keyPattern": {"a": 1}, "hidden": True}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject specifying both name and keyPattern as an invalid option", + ), + CommandTestCase( + "identifier_neither_name_nor_key_pattern", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "index": {"hidden": True}}, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject specifying neither name nor keyPattern as an invalid option", + ), +] + +# Property [Index Identifier No-Match Rejection]: a name or keyPattern that +# matches no existing index is rejected as index-not-found, including an empty +# keyPattern and a text-index keyPattern. +COLLMOD_INDEX_IDENTIFIER_NO_MATCH_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "no_match_name", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "nonexistent", "hidden": True}, + }, + error_code=INDEX_NOT_FOUND_ERROR, + msg="collMod should reject a name that matches no existing index as index-not-found", + ), + CommandTestCase( + "no_match_key_pattern", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"keyPattern": {"b": 1}, "hidden": True}, + }, + error_code=INDEX_NOT_FOUND_ERROR, + msg="collMod should reject a keyPattern that matches no existing index as index-not-found", + ), + CommandTestCase( + "no_match_empty_key_pattern", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"keyPattern": {}, "hidden": True}, + }, + error_code=INDEX_NOT_FOUND_ERROR, + msg="collMod should reject an empty keyPattern as index-not-found", + ), + CommandTestCase( + "no_match_text_key_pattern", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"keyPattern": {"a": "text"}, "hidden": True}, + }, + error_code=INDEX_NOT_FOUND_ERROR, + msg="collMod should reject a text-index keyPattern as index-not-found", + ), +] + +# Property [Index Name Null Rejection]: a null index.name is treated as absent, +# so no identifier is supplied and the command is rejected as an invalid option. +COLLMOD_INDEX_NAME_NULL_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "name_null", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": None, "hidden": True}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should treat a null index name as absent and reject it as an invalid option", + ), +] + +# Property [Index Name Non-String Rejection]: a non-string, non-null index.name +# produces a type-mismatch error. +COLLMOD_INDEX_NAME_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"name_type_{tid}", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "index": {"name": v, "hidden": True}, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} index name as a type mismatch", + ) + for tid, val in [ + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("decimal128", Decimal128("1")), + ("bool_true", True), + ("bool_false", False), + ("array", ["a_1"]), + ("object", {"x": 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 Name Literal Lookup]: an index.name string is always a literal +# lookup key, never a field path or variable, so any string content that names +# no existing index is rejected as index-not-found regardless of length. +COLLMOD_INDEX_NAME_LITERAL_ERROR_TESTS: list[CommandTestCase] = [ + *[ + CommandTestCase( + f"name_literal_{tid}", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "index": {"name": v, "hidden": True}, + }, + error_code=INDEX_NOT_FOUND_ERROR, + msg=f"collMod should treat a {tid} index name as a literal lookup key", + ) + for tid, val in [ + ("empty", ""), + ("space", " "), + ("tab", "\t"), + ("newline", "\n"), + ("cr", "\r"), + ("nbsp", "\u00a0"), # U+00A0 no-break space. + ("name_with_space", "a b"), + ("unicode_2byte", "caf\u00e9"), # U+00E9 with accent. + ("unicode_3byte", "\u4e2d"), # U+4E2D CJK character. + ("unicode_4byte", "\U0001f600"), # U+1F600 emoji. + ("zwsp", "\u200b"), # U+200B zero-width space. + ("bom", "\ufeff"), # U+FEFF byte order mark. + ("dollar", "$"), + ("double_dollar", "$$"), + ("dotted", "a.b.c"), + ("control_low", "\x01"), # U+0001 control char. + ("control_high", "\x1f"), # U+001F control char. + ] + ], + *[ + CommandTestCase( + f"name_literal_large_{size}", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, n=size: { + "collMod": ctx.collection, + "index": {"name": "x" * n, "hidden": True}, + }, + error_code=INDEX_NOT_FOUND_ERROR, + msg="collMod should treat a large index name as a literal lookup key " + "with no length limit", + ) + for size in [16_777_215, 16_777_216, 16_777_217] + ], +] + +# Property [Index Key Pattern Null Rejection]: a null index.keyPattern is treated +# as absent, so no identifier is supplied and the command is rejected as an +# invalid option. +COLLMOD_INDEX_KEY_PATTERN_NULL_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "key_pattern_null", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"keyPattern": None, "hidden": True}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should treat a null keyPattern as absent and reject it as an invalid option", + ), +] + +# Property [Index Key Pattern Non-Object Rejection]: a non-object, non-null +# index.keyPattern produces a type-mismatch error. +COLLMOD_INDEX_KEY_PATTERN_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"key_pattern_type_{tid}", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "index": {"keyPattern": v, "hidden": True}, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} keyPattern as a type mismatch", + ) + for tid, val in [ + ("string", "a_1"), + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("decimal128", Decimal128("1")), + ("bool_true", True), + ("bool_false", False), + ("array", [{"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()), + ] +] + +COLLMOD_INDEX_IDENTIFIER_TESTS: list[CommandTestCase] = ( + COLLMOD_INDEX_RESOLUTION_TESTS + + COLLMOD_INDEX_IDENTIFIER_AMBIGUITY_ERROR_TESTS + + COLLMOD_INDEX_IDENTIFIER_NO_MATCH_ERROR_TESTS + + COLLMOD_INDEX_NAME_NULL_ERROR_TESTS + + COLLMOD_INDEX_NAME_TYPE_ERROR_TESTS + + COLLMOD_INDEX_NAME_LITERAL_ERROR_TESTS + + COLLMOD_INDEX_KEY_PATTERN_NULL_ERROR_TESTS + + COLLMOD_INDEX_KEY_PATTERN_TYPE_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_INDEX_IDENTIFIER_TESTS)) +def test_collMod_index_identifier(database_client, collection, test): + """Test collMod index identifier resolution and rejection.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_index_prepare_unique.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_index_prepare_unique.py new file mode 100644 index 000000000..62821d94d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_index_prepare_unique.py @@ -0,0 +1,317 @@ +"""Tests for collMod index prepareUnique and its combination rule.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) +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.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 +from documentdb_tests.framework.property_checks import Eq, NotExists +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ZERO, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + INT64_ZERO, +) + +# Property [Index prepareUnique Truthy Coercion]: a bool true or any numeric type +# that coerces to true (any nonzero value, including negatives, NaN, and +# Infinity) sets prepareUnique on an index that does not have it, echoing +# prepareUnique_old false and prepareUnique_new true, despite the docs describing +# the field as boolean-only. +COLLMOD_INDEX_PREPARE_UNIQUE_TRUTHY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"prepare_unique_truthy_{tid}", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "index": {"name": "a_1", "prepareUnique": v}, + }, + expected={ + "ok": Eq(1.0), + "prepareUnique_old": Eq(False), + "prepareUnique_new": Eq(True), + }, + msg=f"collMod should coerce a {tid} prepareUnique value to true and echo the change", + ) + for tid, val in [ + ("bool_true", True), + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("decimal128", Decimal128("1")), + ("int32_negative", -1), + ("double_negative", -2.0), + ("decimal128_negative", Decimal128("-1")), + ("float_nan", FLOAT_NAN), + ("decimal128_nan", DECIMAL128_NAN), + ("float_infinity", FLOAT_INFINITY), + ("decimal128_infinity", DECIMAL128_INFINITY), + ("float_negative_infinity", FLOAT_NEGATIVE_INFINITY), + ("decimal128_negative_infinity", DECIMAL128_NEGATIVE_INFINITY), + ] +] + +# Property [Index prepareUnique Falsy Coercion]: any numeric zero (including +# negative zero) coerces to false, so applying it to an index that does not have +# prepareUnique does not change the state and the result omits prepareUnique_old +# and prepareUnique_new. +COLLMOD_INDEX_PREPARE_UNIQUE_FALSY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"prepare_unique_falsy_{tid}", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "index": {"name": "a_1", "prepareUnique": v}, + }, + expected={ + "ok": Eq(1.0), + "prepareUnique_old": NotExists(), + "prepareUnique_new": NotExists(), + }, + msg=f"collMod should coerce a {tid} prepareUnique value to false, leaving the state " + "unchanged", + ) + for tid, val in [ + ("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), + ] +] + +# Property [Index prepareUnique Unchanged State]: setting prepareUnique to its +# current value (re-setting an index that already has prepareUnique or clearing +# one that does not) does not change the state, so the result omits +# prepareUnique_old and prepareUnique_new, while a genuine state change echoes +# both. +COLLMOD_INDEX_PREPARE_UNIQUE_UNCHANGED_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "prepare_unique_already_set", + indexes=[IndexModel([("a", 1)], name="a_1", prepareUnique=True)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "prepareUnique": True}, + }, + expected={ + "ok": Eq(1.0), + "prepareUnique_old": NotExists(), + "prepareUnique_new": NotExists(), + }, + msg="collMod should omit prepareUnique_old and prepareUnique_new when re-setting an " + "already-prepareUnique index", + ), + CommandTestCase( + "prepare_unique_already_unset", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "prepareUnique": False}, + }, + expected={ + "ok": Eq(1.0), + "prepareUnique_old": NotExists(), + "prepareUnique_new": NotExists(), + }, + msg="collMod should omit prepareUnique_old and prepareUnique_new when clearing an index " + "that has no prepareUnique", + ), + CommandTestCase( + "prepare_unique_clear_changes", + indexes=[IndexModel([("a", 1)], name="a_1", prepareUnique=True)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "prepareUnique": False}, + }, + expected={ + "ok": Eq(1.0), + "prepareUnique_old": Eq(True), + "prepareUnique_new": Eq(False), + }, + msg="collMod should echo the change when clearing prepareUnique on an index that has it", + ), +] + +# Property [Index Field Combination prepareUnique No-Change]: a prepareUnique +# value that does not change the index state may be combined with another +# modification field, since the cannot-be-combined rule is gated on an actual +# prepareUnique state change, so the other field applies and prepareUnique_old +# and prepareUnique_new are omitted. +COLLMOD_INDEX_COMBO_PREPARE_UNIQUE_NO_CHANGE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "combo_prepare_unique_unset_no_change_with_hidden", + indexes=[IndexModel([("a", 1)], name="a_1", hidden=False)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "prepareUnique": False, "hidden": True}, + }, + expected={ + "ok": Eq(1.0), + "hidden_old": Eq(False), + "hidden_new": Eq(True), + "prepareUnique_old": NotExists(), + "prepareUnique_new": NotExists(), + }, + msg="collMod should allow a no-change prepareUnique combined with a hidden change", + ), + CommandTestCase( + "combo_prepare_unique_set_no_change_with_hidden", + indexes=[IndexModel([("a", 1)], name="a_1", prepareUnique=True, hidden=False)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "prepareUnique": True, "hidden": True}, + }, + expected={ + "ok": Eq(1.0), + "hidden_old": Eq(False), + "hidden_new": Eq(True), + "prepareUnique_old": NotExists(), + "prepareUnique_new": NotExists(), + }, + msg="collMod should allow a no-change prepareUnique combined with a hidden change", + ), +] + +# Property [Index prepareUnique Non-Bool-Non-Numeric Rejection]: a type outside +# bool and the numeric types for index.prepareUnique produces a type-mismatch +# error. +COLLMOD_INDEX_PREPARE_UNIQUE_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"prepare_unique_type_{tid}", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "index": {"name": "a_1", "prepareUnique": v}, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} prepareUnique value as a type mismatch", + ) + for tid, val in [ + ("string", "true"), + ("array", [True]), + ("object", {"x": 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 prepareUnique Null Rejection]: a null index.prepareUnique is +# treated as absent, leaving no modification field, and is rejected as an +# invalid option. +COLLMOD_INDEX_PREPARE_UNIQUE_NULL_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "prepare_unique_null", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "prepareUnique": None}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should treat a null prepareUnique value as absent and reject it as an " + "invalid option", + ), +] + +# Property [Index Field Combination prepareUnique Change Rejection]: a prepareUnique +# change combined with any other index modification field is rejected as an +# invalid option, since a prepareUnique state change cannot be combined with +# another modification. +COLLMOD_INDEX_COMBO_PREPARE_UNIQUE_CHANGE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "combo_prepare_unique_change_with_hidden", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "prepareUnique": True, "hidden": True}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject a prepareUnique change combined with a hidden change " + "as an invalid option", + ), + CommandTestCase( + "combo_prepare_unique_change_with_expire", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "prepareUnique": True, "expireAfterSeconds": 100}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject a prepareUnique change combined with an expireAfterSeconds " + "change as an invalid option", + ), +] + +COLLMOD_INDEX_PREPARE_UNIQUE_TESTS: list[CommandTestCase] = ( + COLLMOD_INDEX_PREPARE_UNIQUE_TRUTHY_TESTS + + COLLMOD_INDEX_PREPARE_UNIQUE_FALSY_TESTS + + COLLMOD_INDEX_PREPARE_UNIQUE_UNCHANGED_TESTS + + COLLMOD_INDEX_COMBO_PREPARE_UNIQUE_NO_CHANGE_TESTS + + COLLMOD_INDEX_PREPARE_UNIQUE_TYPE_ERROR_TESTS + + COLLMOD_INDEX_PREPARE_UNIQUE_NULL_ERROR_TESTS + + COLLMOD_INDEX_COMBO_PREPARE_UNIQUE_CHANGE_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_INDEX_PREPARE_UNIQUE_TESTS)) +def test_collMod_index_prepare_unique(database_client, collection, test): + """Test collMod index prepareUnique acceptance, rejection, and combination rule.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_index_unique.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_index_unique.py new file mode 100644 index 000000000..483737710 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_index_unique.py @@ -0,0 +1,241 @@ +"""Tests for collMod index unique conversion.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) +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.error_codes import ( + BAD_VALUE_ERROR, + CANNOT_CONVERT_INDEX_TO_UNIQUE_ERROR, + INVALID_OPTIONS_ERROR, + 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, NotExists +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ZERO, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + INT64_ZERO, +) + +# Property [Index unique Truthy Conversion]: on an index with prepareUnique +# already committed and no duplicate entries, a bool true or any numeric type +# that coerces to true (any nonzero value, including negatives, NaN, and +# Infinity) converts the index to unique and echoes unique_new true. +COLLMOD_INDEX_UNIQUE_TRUTHY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"unique_truthy_{tid}", + indexes=[IndexModel([("a", 1)], name="a_1", prepareUnique=True)], + docs=[{"_id": 1, "a": 1}, {"_id": 2, "a": 2}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "index": {"name": "a_1", "unique": v}, + }, + expected={"ok": Eq(1.0), "unique_new": Eq(True), "unique_old": NotExists()}, + msg=f"collMod should coerce a {tid} unique value to true and convert the index", + ) + for tid, val in [ + ("bool_true", True), + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("decimal128", Decimal128("1")), + ("int32_negative", -1), + ("double_negative", -2.0), + ("decimal128_negative", Decimal128("-1")), + ("float_nan", FLOAT_NAN), + ("decimal128_nan", DECIMAL128_NAN), + ("float_infinity", FLOAT_INFINITY), + ("decimal128_infinity", DECIMAL128_INFINITY), + ("float_negative_infinity", FLOAT_NEGATIVE_INFINITY), + ("decimal128_negative_infinity", DECIMAL128_NEGATIVE_INFINITY), + ] +] + +# Property [Index unique Already Unique No-Op]: setting unique true on an index +# that is already unique is an accepted no-op that omits unique_old and +# unique_new from the result. +COLLMOD_INDEX_UNIQUE_NO_OP_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "unique_already_unique", + indexes=[IndexModel([("a", 1)], name="a_1", unique=True)], + docs=[{"_id": 1, "a": 1}, {"_id": 2, "a": 2}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "unique": True}, + }, + expected={ + "ok": Eq(1.0), + "unique_old": NotExists(), + "unique_new": NotExists(), + }, + msg="collMod should accept unique true on an already-unique index as a no-op", + ), +] + +# Property [Index unique Non-Bool-Non-Numeric Rejection]: a type outside bool +# and the numeric types for index.unique produces a type-mismatch error. +COLLMOD_INDEX_UNIQUE_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"unique_type_{tid}", + indexes=[IndexModel([("a", 1)], name="a_1", prepareUnique=True)], + docs=[{"_id": 1, "a": 1}, {"_id": 2, "a": 2}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "index": {"name": "a_1", "unique": v}, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} unique value as a type mismatch", + ) + for tid, val in [ + ("string", "true"), + ("array", [True]), + ("object", {"x": 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 unique Null Rejection]: a null index.unique is treated as +# absent, leaving no modification field, and is rejected as an invalid option. +COLLMOD_INDEX_UNIQUE_NULL_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "unique_null", + indexes=[IndexModel([("a", 1)], name="a_1", prepareUnique=True)], + docs=[{"_id": 1, "a": 1}, {"_id": 2, "a": 2}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "unique": None}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should treat a null unique value as absent and reject it as an invalid " + "option", + ), +] + +# Property [Index unique True Without prepareUnique Rejection]: a truthy unique +# value on an index that has no committed prepareUnique is rejected as an invalid +# option, since the conversion requires prepareUnique to be set first. +COLLMOD_INDEX_UNIQUE_NO_PREPARE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "unique_true_no_prepare", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}, {"_id": 2, "a": 2}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "unique": True}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject a truthy unique value without a prior prepareUnique " + "as an invalid option", + ), +] + +# Property [Index unique Falsy Rejection]: a falsy unique value (bool false or +# any numeric zero) attempts to make the index non-unique, which is unsupported, +# and is rejected as a bad value. +COLLMOD_INDEX_UNIQUE_FALSY_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"unique_falsy_{tid}", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}, {"_id": 2, "a": 2}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "index": {"name": "a_1", "unique": v}, + }, + error_code=BAD_VALUE_ERROR, + msg=f"collMod should reject a {tid} falsy unique value as a bad value", + ) + for tid, val in [ + ("bool_false", False), + ("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), + ] +] + +# Property [Index unique Conversion With Duplicates Rejection]: the conversion +# workflow of prepareUnique then unique true on an index whose data contains +# duplicate entries cannot convert and is rejected as a cannot-convert error. +COLLMOD_INDEX_UNIQUE_CONVERT_DUP_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "unique_convert_with_duplicates", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}, {"_id": 2, "a": 1}], + setup=lambda coll: execute_command( + coll, + {"collMod": coll.name, "index": {"name": "a_1", "prepareUnique": True}}, + ), + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "unique": True}, + }, + error_code=CANNOT_CONVERT_INDEX_TO_UNIQUE_ERROR, + msg="collMod should reject a unique conversion on an index with duplicate entries", + ), +] + +COLLMOD_INDEX_UNIQUE_TESTS: list[CommandTestCase] = ( + COLLMOD_INDEX_UNIQUE_TRUTHY_TESTS + + COLLMOD_INDEX_UNIQUE_NO_OP_TESTS + + COLLMOD_INDEX_UNIQUE_TYPE_ERROR_TESTS + + COLLMOD_INDEX_UNIQUE_NULL_ERROR_TESTS + + COLLMOD_INDEX_UNIQUE_NO_PREPARE_ERROR_TESTS + + COLLMOD_INDEX_UNIQUE_FALSY_ERROR_TESTS + + COLLMOD_INDEX_UNIQUE_CONVERT_DUP_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_INDEX_UNIQUE_TESTS)) +def test_collMod_index_unique(database_client, collection, test): + """Test collMod index unique conversion acceptance and rejection.""" + collection = test.prepare(database_client, collection) + if test.setup: + test.setup(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/core/collections/commands/collMod/test_collMod_interactions.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_interactions.py new file mode 100644 index 000000000..58f6eba7d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_interactions.py @@ -0,0 +1,417 @@ +"""Tests for collMod cross-option parameter interactions.""" + +from __future__ import annotations + +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.error_codes import ( + INDEX_NOT_FOUND_ERROR, + INVALID_OPTIONS_ERROR, + UNRECOGNIZED_COMMAND_FIELD_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 +from documentdb_tests.framework.target_collection import ( + CappedCollection, + ClusteredCollection, + TimeseriesCollection, + TimeseriesTTLCollection, + ViewCollection, +) + +# Property [Cross-Group Coexistence]: independent option groups that target the +# same collection type apply together in one command, and each group's effect is +# reflected in the result independently of the others. +COLLMOD_CROSS_GROUP_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "index_hidden_and_validator", + indexes=[IndexModel([("a", 1)], name="a_1", hidden=False)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "hidden": True}, + "validator": {"a": 1}, + }, + expected={"ok": Eq(1.0), "hidden_old": Eq(False), "hidden_new": Eq(True)}, + msg="collMod should apply an index hidden change together with a validator", + ), + CommandTestCase( + "index_hidden_and_validation_level", + indexes=[IndexModel([("a", 1)], name="a_1", hidden=False)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "hidden": True}, + "validationLevel": "moderate", + }, + expected={"ok": Eq(1.0), "hidden_old": Eq(False), "hidden_new": Eq(True)}, + msg="collMod should apply an index hidden change together with a validationLevel", + ), + CommandTestCase( + "validator_and_change_stream_pre_and_post_images", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "validator": {"a": 1}, + "changeStreamPreAndPostImages": {"enabled": True}, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should apply a validator together with changeStreamPreAndPostImages", + ), + CommandTestCase( + "validator_and_validation_level_and_action", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "validator": {"a": 1}, + "validationLevel": "strict", + "validationAction": "error", + }, + expected={"ok": Eq(1.0)}, + msg="collMod should apply a validator together with validationLevel and validationAction", + ), +] + +# Property [Clustered Index And Top-Level expireAfterSeconds]: on a clustered +# collection, an index hidden change and a top-level expireAfterSeconds both +# apply in one command, echoing the index hidden state change. +COLLMOD_CLUSTERED_INDEX_AND_EXPIRE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "clustered_index_hidden_and_expire", + target_collection=ClusteredCollection(), + indexes=[IndexModel([("a", 1)], name="a_1", hidden=False)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "hidden": True}, + "expireAfterSeconds": 100, + }, + expected={"ok": Eq(1.0), "hidden_old": Eq(False), "hidden_new": Eq(True)}, + msg="collMod should apply an index hidden change and a top-level expireAfterSeconds " + "on a clustered collection", + ), +] + +# Property [Validation Options Without Existing Validator]: validationLevel and +# validationAction set together on a collection that never had a validator are +# accepted, so they do not require a pre-existing validator. +COLLMOD_VALIDATION_NO_PRIOR_VALIDATOR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "validation_level_and_action_together", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "validationLevel": "moderate", + "validationAction": "warn", + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept validationLevel and validationAction together with no " + "pre-existing validator", + ), +] + +# Property [All-Null Across Groups]: when every cross-group option is null, the +# command is a no-op success that echoes no modification fields. +COLLMOD_ALL_NULL_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "all_null_across_groups", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "validator": None, + "validationLevel": None, + "validationAction": None, + "index": None, + "changeStreamPreAndPostImages": None, + }, + expected={ + "ok": Eq(1.0), + "hidden_old": NotExists(), + "hidden_new": NotExists(), + "expireAfterSeconds_old": NotExists(), + "expireAfterSeconds_new": NotExists(), + }, + msg="collMod should treat all-null cross-group options as a no-op", + ), +] + +# Property [Time Series Cross-Group Coexistence]: a timeseries modification +# applies together with a top-level expireAfterSeconds, a comment, or a +# writeConcern on a time series collection. +COLLMOD_TIMESERIES_CROSS_GROUP_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "timeseries_and_top_level_expire", + target_collection=TimeseriesTTLCollection(), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "timeseries": {"granularity": "minutes"}, + "expireAfterSeconds": 100, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should apply a timeseries modification and a top-level expireAfterSeconds " + "on a time series collection", + ), + CommandTestCase( + "timeseries_and_comment", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "timeseries": {"granularity": "minutes"}, + "comment": "hello", + }, + expected={"ok": Eq(1.0)}, + msg="collMod should apply a timeseries modification together with a comment", + ), + CommandTestCase( + "timeseries_and_write_concern", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "timeseries": {"granularity": "minutes"}, + "writeConcern": {}, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should apply a timeseries modification together with a writeConcern", + ), +] + +# Property [Time Series Ignores Capped Options]: a timeseries modification +# combined with cappedSize or cappedMax on a time series collection is a silent +# no-op success, since the capped options are ignored rather than rejected. +COLLMOD_TIMESERIES_CAPPED_NOOP_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "timeseries_and_capped_size", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "timeseries": {}, + "cappedSize": 100_000, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should silently ignore cappedSize on a time series collection", + ), + CommandTestCase( + "timeseries_and_capped_max", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "timeseries": {}, + "cappedMax": 1000, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should silently ignore cappedMax on a time series collection", + ), +] + +# Property [View Cross-Group Coexistence]: view options coexist in one command, +# so a null viewOn paired with a null pipeline is a no-op success and a viewOn +# value paired with a comment is accepted. +COLLMOD_VIEW_CROSS_GROUP_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "view_on_null_and_pipeline_null", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "viewOn": None, "pipeline": None}, + expected={"ok": Eq(1.0)}, + msg="collMod should treat a null viewOn and null pipeline as a no-op", + ), + CommandTestCase( + "view_on_and_comment", + target_collection=ViewCollection(), + command=lambda ctx: { + "collMod": ctx.collection, + "viewOn": "some_source", + "comment": "hello", + }, + expected={"ok": Eq(1.0)}, + msg="collMod should apply a viewOn together with a comment", + ), +] + +# Property [Capped Size And Validator Coexistence]: a cappedSize modification +# and a validator apply together in one command on a capped collection. +COLLMOD_CAPPED_SIZE_AND_VALIDATOR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "capped_size_and_validator", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "cappedSize": 100_000, + "validator": {"a": 1}, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should apply a cappedSize and a validator together on a capped collection", + ), +] + +# Property [Validation Change Result Shape]: a successful validator, +# validationLevel, or validationAction change returns ok:1.0 and echoes no +# old/new modification fields, unlike an index hidden or expireAfterSeconds +# change which does echo them. +COLLMOD_VALIDATION_RESULT_SHAPE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "result_shape_validator", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "validator": {"a": 1}}, + expected={ + "ok": Eq(1.0), + "validator_old": NotExists(), + "validator_new": NotExists(), + }, + msg="collMod should return ok:1.0 with no old/new echo for a validator change", + ), + CommandTestCase( + "result_shape_validation_level", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "validationLevel": "moderate"}, + expected={ + "ok": Eq(1.0), + "validationLevel_old": NotExists(), + "validationLevel_new": NotExists(), + }, + msg="collMod should return ok:1.0 with no old/new echo for a validationLevel change", + ), + CommandTestCase( + "result_shape_validation_action", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "validationAction": "warn"}, + expected={ + "ok": Eq(1.0), + "validationAction_old": NotExists(), + "validationAction_new": NotExists(), + }, + msg="collMod should return ok:1.0 with no old/new echo for a validationAction change", + ), +] + +COLLMOD_INTERACTIONS_SUCCESS_TESTS: list[CommandTestCase] = ( + COLLMOD_CROSS_GROUP_TESTS + + COLLMOD_CLUSTERED_INDEX_AND_EXPIRE_TESTS + + COLLMOD_VALIDATION_NO_PRIOR_VALIDATOR_TESTS + + COLLMOD_ALL_NULL_TESTS + + COLLMOD_TIMESERIES_CROSS_GROUP_TESTS + + COLLMOD_TIMESERIES_CAPPED_NOOP_TESTS + + COLLMOD_VIEW_CROSS_GROUP_TESTS + + COLLMOD_CAPPED_SIZE_AND_VALIDATOR_TESTS + + COLLMOD_VALIDATION_RESULT_SHAPE_TESTS +) + +# Property [Unrelated Option Does Not Suppress Index Resolution]: when an index +# modification names a nonexistent index, an unrelated option group present in +# the same command does not suppress the index lookup, so the index-not-found +# error still surfaces. +COLLMOD_INDEX_RESOLUTION_NOT_SUPPRESSED_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "capped_size_and_index_missing", + target_collection=CappedCollection(), + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "cappedSize": 200_000, + "index": {"name": "nonexistent", "hidden": True}, + }, + error_code=INDEX_NOT_FOUND_ERROR, + msg="collMod should surface an index-not-found error when a missing index is combined " + "with a cappedSize", + ), +] + +# Property [View Options On A Regular Collection]: a view-only option applied to +# a regular (non-view) collection is rejected rather than converting the +# collection into a view. +COLLMOD_VIEW_OPTION_ON_REGULAR_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "regular_collection_view_on", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "viewOn": "some_source"}, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject a viewOn applied to a regular collection", + ), + CommandTestCase( + "regular_collection_pipeline", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "pipeline": []}, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject a pipeline applied to a regular collection", + ), +] + +# Property [Time Series Index Resolution]: an index modification on a time +# series collection resolves against the system.buckets namespace that backs the +# collection, so a nonexistent index produces an index-not-found error +# referencing that namespace. +COLLMOD_TIMESERIES_INDEX_RESOLUTION_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "timeseries_index_resolves_against_bucket_namespace", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "nonexistent", "hidden": True}, + }, + error_code=INDEX_NOT_FOUND_ERROR, + msg="collMod should resolve an index on a time series collection against the " + "bucket-backing namespace, producing an index-not-found error", + ), +] + +# Property [Top-Level Unknown Field Rejection]: an unrecognized top-level command +# field is rejected, and field-name matching is case-sensitive, so a case-variant +# of a known option is rejected too. +COLLMOD_TOP_LEVEL_UNKNOWN_FIELD_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "unknown_top_level_field", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "unknownField": "hello"}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="collMod should reject an unrecognized top-level command field", + ), + CommandTestCase( + "case_variant_top_level_field", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "CappedSize": 100_000}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="collMod should reject a case-variant of a known top-level field as unrecognized", + ), +] + +COLLMOD_INTERACTIONS_ERROR_TESTS: list[CommandTestCase] = ( + COLLMOD_INDEX_RESOLUTION_NOT_SUPPRESSED_ERROR_TESTS + + COLLMOD_VIEW_OPTION_ON_REGULAR_ERROR_TESTS + + COLLMOD_TIMESERIES_INDEX_RESOLUTION_ERROR_TESTS + + COLLMOD_TOP_LEVEL_UNKNOWN_FIELD_ERROR_TESTS +) + +COLLMOD_INTERACTIONS_TESTS: list[CommandTestCase] = ( + COLLMOD_INTERACTIONS_SUCCESS_TESTS + COLLMOD_INTERACTIONS_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_INTERACTIONS_TESTS)) +def test_collMod_interactions(database_client, collection, test): + """Test collMod cross-option parameter interactions.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_pipeline.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_pipeline.py new file mode 100644 index 000000000..41ddc2d55 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_pipeline.py @@ -0,0 +1,412 @@ +"""Tests for collMod view pipeline.""" + +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 ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + CHANGE_STREAM_NOT_ALLOWED_ERROR, + FACET_PIPELINE_INVALID_STAGE_ERROR, + GRAPH_CONTAINS_CYCLE_ERROR, + INVALID_NAMESPACE_ERROR, + LOOKUP_SUB_PIPELINE_NOT_ALLOWED_ERROR, + OPTION_NOT_SUPPORTED_ON_VIEW_ERROR, + PIPELINE_STAGE_EXTRA_FIELD_ERROR, + TYPE_MISMATCH_ERROR, + UNION_WITH_SUB_PIPELINE_NOT_ALLOWED_ERROR, + UNKNOWN_PIPELINE_STAGE_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.target_collection import ViewCollection +from documentdb_tests.framework.test_constants import ( + DECIMAL128_ONE_AND_HALF, +) + +# Property [pipeline Success]: an array pipeline is accepted as a view +# definition, null is accepted as an omitted field, null elements are silently +# dropped, a large stage count is accepted, and a range of stages valid in a +# view definition are accepted. +COLLMOD_PIPELINE_SUCCESS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "empty_array", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "pipeline": []}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept an empty pipeline array", + ), + CommandTestCase( + "single_stage", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "pipeline": [{"$match": {"a": 1}}]}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a pipeline with a single valid object stage", + ), + CommandTestCase( + "pipeline_null", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "pipeline": None}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a null pipeline as an omitted field", + ), + CommandTestCase( + "single_null_element", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "pipeline": [None]}, + expected={"ok": Eq(1.0)}, + msg="collMod should silently drop a lone null pipeline element", + ), + CommandTestCase( + "stage_then_null_element", + target_collection=ViewCollection(), + command=lambda ctx: { + "collMod": ctx.collection, + "pipeline": [{"$match": {"a": 1}}, None], + }, + expected={"ok": Eq(1.0)}, + msg="collMod should silently drop a trailing null pipeline element while keeping the stage", + ), + CommandTestCase( + "many_stages", + target_collection=ViewCollection(), + # The server caps a view pipeline's stage count below the standard 10_000 + # stress value, so 1000 stages is the largest count shown to be accepted. + command=lambda ctx: {"collMod": ctx.collection, "pipeline": [{"$match": {"a": 1}}] * 1000}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a pipeline with 1000 stages", + ), + CommandTestCase( + "coll_stats_stage", + target_collection=ViewCollection(), + command=lambda ctx: { + "collMod": ctx.collection, + "pipeline": [{"$collStats": {"storageStats": {}}}], + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a $collStats stage in a view definition", + ), + CommandTestCase( + "index_stats_stage", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "pipeline": [{"$indexStats": {}}]}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept an $indexStats stage in a view definition", + ), + CommandTestCase( + "sample_stage", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "pipeline": [{"$sample": {"size": 1}}]}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a $sample stage in a view definition", + ), + CommandTestCase( + "lookup_stage", + target_collection=ViewCollection(), + command=lambda ctx: { + "collMod": ctx.collection, + "pipeline": [ + { + "$lookup": { + "from": "other", + "localField": "a", + "foreignField": "a", + "as": "r", + } + } + ], + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a $lookup stage in a view definition", + ), + CommandTestCase( + "graph_lookup_stage", + target_collection=ViewCollection(), + command=lambda ctx: { + "collMod": ctx.collection, + "pipeline": [ + { + "$graphLookup": { + "from": "other", + "startWith": "$a", + "connectFromField": "a", + "connectToField": "a", + "as": "r", + } + } + ], + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a $graphLookup stage in a view definition", + ), + CommandTestCase( + "facet_stage", + target_collection=ViewCollection(), + command=lambda ctx: { + "collMod": ctx.collection, + "pipeline": [{"$facet": {"f": [{"$match": {"a": 1}}]}}], + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a $facet stage in a view definition", + ), + CommandTestCase( + "union_with_stage", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "pipeline": [{"$unionWith": "other"}]}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a $unionWith stage in a view definition", + ), +] + +# Property [pipeline Type Rejection]: any non-array value for pipeline produces +# a TypeMismatch error. +COLLMOD_PIPELINE_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"pipeline_type_{tid}", + target_collection=ViewCollection(), + command=lambda ctx, v=val: {"collMod": ctx.collection, "pipeline": v}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} pipeline as a non-array", + ) + for tid, val in [ + ("string", "x"), + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool_true", True), + ("bool_false", False), + ("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 [pipeline Element Type Rejection]: any non-object, non-null array +# element produces a TypeMismatch error. +COLLMOD_PIPELINE_ELEMENT_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "element_string", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "pipeline": ["x"]}, + error_code=TYPE_MISMATCH_ERROR, + msg="collMod should reject a non-object pipeline element", + ), + CommandTestCase( + "element_null_then_string", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "pipeline": [None, "x"]}, + error_code=TYPE_MISMATCH_ERROR, + msg="collMod should reject a non-object element after a dropped null element", + ), +] + +# Property [pipeline Stage Shape Rejection]: a stage object that does not contain +# exactly one field produces a stage-shape error. +COLLMOD_PIPELINE_STAGE_SHAPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "empty_stage", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "pipeline": [{}]}, + error_code=PIPELINE_STAGE_EXTRA_FIELD_ERROR, + msg="collMod should reject an empty stage object", + ), + CommandTestCase( + "two_key_stage", + target_collection=ViewCollection(), + command=lambda ctx: { + "collMod": ctx.collection, + "pipeline": [{"$match": {}, "$limit": 1}], + }, + error_code=PIPELINE_STAGE_EXTRA_FIELD_ERROR, + msg="collMod should reject a stage object with two fields", + ), +] + +# Property [pipeline Unknown Stage Rejection]: a stage whose key is not a +# recognized pipeline stage name produces an unrecognized-stage error, including +# a key with no dollar prefix. +COLLMOD_PIPELINE_UNKNOWN_STAGE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "unknown_stage", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "pipeline": [{"$nope": {}}]}, + error_code=UNKNOWN_PIPELINE_STAGE_ERROR, + msg="collMod should reject an unknown pipeline stage name", + ), + CommandTestCase( + "dollarless_stage", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "pipeline": [{"match": {}}]}, + error_code=UNKNOWN_PIPELINE_STAGE_ERROR, + msg="collMod should reject a pipeline stage key with no dollar prefix", + ), +] + +# Property [pipeline Prohibited Output Stage Rejection]: a $out or $merge stage +# is prohibited in a view definition, with the error code determined by its +# nesting location. +COLLMOD_PIPELINE_OUTPUT_STAGE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "out_top_level", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "pipeline": [{"$out": "dest"}]}, + error_code=OPTION_NOT_SUPPORTED_ON_VIEW_ERROR, + msg="collMod should reject a top-level $out stage in a view definition", + ), + CommandTestCase( + "merge_top_level", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "pipeline": [{"$merge": "dest"}]}, + error_code=OPTION_NOT_SUPPORTED_ON_VIEW_ERROR, + msg="collMod should reject a top-level $merge stage in a view definition", + ), + CommandTestCase( + "out_in_lookup", + target_collection=ViewCollection(), + command=lambda ctx: { + "collMod": ctx.collection, + "pipeline": [{"$lookup": {"from": "other", "as": "r", "pipeline": [{"$out": "dest"}]}}], + }, + error_code=LOOKUP_SUB_PIPELINE_NOT_ALLOWED_ERROR, + msg="collMod should reject a $out stage inside a $lookup sub-pipeline", + ), + CommandTestCase( + "out_in_facet", + target_collection=ViewCollection(), + command=lambda ctx: { + "collMod": ctx.collection, + "pipeline": [{"$facet": {"f": [{"$out": "dest"}]}}], + }, + error_code=FACET_PIPELINE_INVALID_STAGE_ERROR, + msg="collMod should reject a $out stage inside a $facet", + ), + CommandTestCase( + "out_in_union_with", + target_collection=ViewCollection(), + command=lambda ctx: { + "collMod": ctx.collection, + "pipeline": [{"$unionWith": {"coll": "other", "pipeline": [{"$out": "dest"}]}}], + }, + error_code=UNION_WITH_SUB_PIPELINE_NOT_ALLOWED_ERROR, + msg="collMod should reject a $out stage inside a $unionWith sub-pipeline", + ), +] + +# Property [pipeline View-Incompatible Stage Rejection]: a $changeStream stage is +# rejected as an option not supported on a view, and $documents, $currentOp, and +# $listSessions stages are rejected as invalid namespaces in a view definition. +COLLMOD_PIPELINE_VIEW_INCOMPATIBLE_STAGE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "change_stream_stage", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "pipeline": [{"$changeStream": {}}]}, + error_code=OPTION_NOT_SUPPORTED_ON_VIEW_ERROR, + msg="collMod should reject a $changeStream stage in a view definition", + marks=(pytest.mark.requires(change_streams=True),), + ), + CommandTestCase( + "change_stream_stage_unavailable", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "pipeline": [{"$changeStream": {}}]}, + error_code=CHANGE_STREAM_NOT_ALLOWED_ERROR, + msg="collMod should reject a $changeStream stage where change streams are unavailable", + marks=(pytest.mark.requires(change_streams=False),), + ), + CommandTestCase( + "documents_stage", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "pipeline": [{"$documents": []}]}, + error_code=INVALID_NAMESPACE_ERROR, + msg="collMod should reject a $documents stage in a view definition", + ), + CommandTestCase( + "current_op_stage", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "pipeline": [{"$currentOp": {}}]}, + error_code=INVALID_NAMESPACE_ERROR, + msg="collMod should reject a $currentOp stage in a view definition", + ), + CommandTestCase( + "list_sessions_stage", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "pipeline": [{"$listSessions": {}}]}, + error_code=INVALID_NAMESPACE_ERROR, + msg="collMod should reject a $listSessions stage in a view definition", + ), +] + +# Property [pipeline Self Reference Rejection]: a $lookup or $unionWith stage +# that references the view's own name produces a GraphContainsCycle error. +COLLMOD_PIPELINE_CYCLE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "lookup_self_reference", + target_collection=ViewCollection(), + command=lambda ctx: { + "collMod": ctx.collection, + "pipeline": [ + { + "$lookup": { + "from": ctx.collection, + "localField": "a", + "foreignField": "a", + "as": "r", + } + } + ], + }, + error_code=GRAPH_CONTAINS_CYCLE_ERROR, + msg="collMod should reject a $lookup self-reference as a cycle", + ), + CommandTestCase( + "union_with_self_reference", + target_collection=ViewCollection(), + command=lambda ctx: { + "collMod": ctx.collection, + "pipeline": [{"$unionWith": ctx.collection}], + }, + error_code=GRAPH_CONTAINS_CYCLE_ERROR, + msg="collMod should reject a $unionWith self-reference as a cycle", + ), +] + +COLLMOD_PIPELINE_TESTS: list[CommandTestCase] = ( + COLLMOD_PIPELINE_SUCCESS_TESTS + + COLLMOD_PIPELINE_TYPE_ERROR_TESTS + + COLLMOD_PIPELINE_ELEMENT_TYPE_ERROR_TESTS + + COLLMOD_PIPELINE_STAGE_SHAPE_ERROR_TESTS + + COLLMOD_PIPELINE_UNKNOWN_STAGE_ERROR_TESTS + + COLLMOD_PIPELINE_OUTPUT_STAGE_ERROR_TESTS + + COLLMOD_PIPELINE_VIEW_INCOMPATIBLE_STAGE_ERROR_TESTS + + COLLMOD_PIPELINE_CYCLE_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_PIPELINE_TESTS)) +def test_collMod_pipeline(database_client, collection, test): + """Test collMod view pipeline acceptance and rejection.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_read_concern.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_read_concern.py new file mode 100644 index 000000000..f4402477e --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_read_concern.py @@ -0,0 +1,201 @@ +"""Tests for the collMod readConcern option.""" + +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 ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_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 +from documentdb_tests.framework.property_checks import Eq +from documentdb_tests.framework.test_constants import DECIMAL128_ONE_AND_HALF + +# Property [readConcern Acceptance]: readConcern is accepted when it is an empty +# document, the supported "local" level, null (treated as omitted), or an object +# whose level sub-field is null (treated as absent), without changing the +# command result. +COLLMOD_READ_CONCERN_SUCCESS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "empty_document", + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "readConcern": {}}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept an empty readConcern document", + ), + CommandTestCase( + "level_local", + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "readConcern": {"level": "local"}}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a readConcern level of local", + ), + CommandTestCase( + "null", + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "readConcern": None}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a null readConcern as an omitted field", + ), + CommandTestCase( + "level_null", + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "readConcern": {"level": None}}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a null readConcern level as an absent sub-field", + ), +] + +# Property [readConcern Unsupported Level Rejection]: a recognized read concern +# level that collMod does not support (majority, available, snapshot, +# linearizable) produces an InvalidOptions error. +COLLMOD_READ_CONCERN_UNSUPPORTED_LEVEL_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"unsupported_{level}", + docs=[], + command=lambda ctx, v=level: {"collMod": ctx.collection, "readConcern": {"level": v}}, + error_code=INVALID_OPTIONS_ERROR, + msg=f"collMod should reject the {level} readConcern level as unsupported", + ) + for level in ["majority", "available", "snapshot", "linearizable"] +] + +# Property [readConcern Invalid Level Enum Rejection]: the level enum is +# case-sensitive and applies no whitespace trimming, so any string that is not a +# recognized level produces a BadValue error. +COLLMOD_READ_CONCERN_INVALID_LEVEL_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"invalid_level_{tid}", + docs=[], + command=lambda ctx, v=val: {"collMod": ctx.collection, "readConcern": {"level": v}}, + error_code=BAD_VALUE_ERROR, + msg=f"collMod should reject {tid} as a readConcern level enum value", + ) + for tid, val in [ + ("empty", ""), + ("arbitrary", "bogus"), + ("capitalized", "Local"), + ("uppercase", "LOCAL"), + ("trailing_space", "local "), + ] +] + +# Property [readConcern.level Type Rejection]: a level sub-field value that is +# neither a string nor null produces a TypeMismatch error. +COLLMOD_READ_CONCERN_LEVEL_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"level_type_{tid}", + docs=[], + command=lambda ctx, v=val: {"collMod": ctx.collection, "readConcern": {"level": v}}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} readConcern level as a non-string", + ) + for tid, val in [ + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool_true", True), + ("bool_false", False), + ("array", ["local"]), + ("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 [readConcern Top-Level Type Rejection]: a readConcern value that is +# neither an object nor null produces a TypeMismatch error. +COLLMOD_READ_CONCERN_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"type_{tid}", + docs=[], + command=lambda ctx, v=val: {"collMod": ctx.collection, "readConcern": v}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} readConcern as a non-object", + ) + for tid, val in [ + ("string", "local"), + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool_true", True), + ("bool_false", False), + ("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 Rejection]: an unrecognized +# readConcern sub-field produces an UnknownField error, and that error fires +# even when the supported level sub-field is also present. +COLLMOD_READ_CONCERN_UNKNOWN_FIELD_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "unknown_field_only", + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "readConcern": {"bogus": 1}}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="collMod should reject an unknown readConcern sub-field", + ), + CommandTestCase( + "unknown_field_with_level", + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "readConcern": {"level": "local", "bogus": 1}, + }, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="collMod should reject an unknown readConcern sub-field even with a level present", + ), +] + +COLLMOD_READ_CONCERN_ALL_TESTS: list[CommandTestCase] = ( + COLLMOD_READ_CONCERN_SUCCESS_TESTS + + COLLMOD_READ_CONCERN_UNSUPPORTED_LEVEL_ERROR_TESTS + + COLLMOD_READ_CONCERN_INVALID_LEVEL_ERROR_TESTS + + COLLMOD_READ_CONCERN_LEVEL_TYPE_ERROR_TESTS + + COLLMOD_READ_CONCERN_TYPE_ERROR_TESTS + + COLLMOD_READ_CONCERN_UNKNOWN_FIELD_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_READ_CONCERN_ALL_TESTS)) +def test_collMod_read_concern(database_client, collection, test): + """Test collMod readConcern option behavior.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_target_name.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_target_name.py new file mode 100644 index 000000000..6138c9a27 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_target_name.py @@ -0,0 +1,151 @@ +"""Tests for collMod target name (the collMod command value) acceptance.""" + +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_NAMESPACE_ERROR, + NAMESPACE_NOT_FOUND_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.target_collection import ( + CappedCollection, + ClusteredCollection, + TimeseriesCollection, + ViewCollection, +) + +# Property [Target Name Resolution]: a string naming an existing collection or +# view in the current database resolves successfully, and with no option field +# present the command is a no-op success regardless of the target's type. +COLLMOD_TARGET_NAME_RESOLUTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "plain_collection", + docs=[{"_id": 1}], + command=lambda ctx: {"collMod": ctx.collection}, + expected={"ok": Eq(1.0)}, + msg="collMod should resolve a plain collection name as a no-op success", + ), + CommandTestCase( + "capped_collection", + target_collection=CappedCollection(size=4096), + docs=[{"_id": 1}], + command=lambda ctx: {"collMod": ctx.collection}, + expected={"ok": Eq(1.0)}, + msg="collMod should resolve a capped collection name as a no-op success", + ), + CommandTestCase( + "view", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection}, + expected={"ok": Eq(1.0)}, + msg="collMod should resolve a view name as a no-op success", + ), + CommandTestCase( + "timeseries_collection", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection}, + expected={"ok": Eq(1.0)}, + msg="collMod should resolve a time series collection name as a no-op success", + ), + CommandTestCase( + "clustered_collection", + target_collection=ClusteredCollection(), + docs=[{"_id": 1}], + command=lambda ctx: {"collMod": ctx.collection}, + expected={"ok": Eq(1.0)}, + msg="collMod should resolve a clustered collection name as a no-op success", + ), +] + +# Property [Target Name Validation Wiring]: a structurally invalid name and a +# valid name that matches no collection each produce the expected namespace +# error. +COLLMOD_TARGET_NAME_WIRING_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "invalid_namespace_empty_string", + command={"collMod": ""}, + error_code=INVALID_NAMESPACE_ERROR, + msg="collMod should reject a structurally invalid namespace as an invalid namespace", + ), + CommandTestCase( + "not_found_nonexistent", + docs=None, + command=lambda ctx: {"collMod": ctx.collection}, + error_code=NAMESPACE_NOT_FOUND_ERROR, + msg="collMod should reject a valid name that matches no collection as not found", + ), +] + +# Property [Non-String Target Type Errors]: any non-string type for the target +# (including null and any array shape) produces an invalid namespace error. +COLLMOD_TARGET_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"type_{tid}", + command={"collMod": val}, + error_code=INVALID_NAMESPACE_ERROR, + msg=f"collMod should reject a {tid} target as an invalid namespace type", + ) + for tid, val in [ + ("int32", 123), + ("int64", Int64(1)), + ("double", 1.5), + ("decimal128", Decimal128("1")), + ("bool_true", True), + ("bool_false", False), + ("null", None), + ("object", {"x": 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()), + ("array_empty", []), + ("array_string", ["target"]), + ] +] + +COLLMOD_TARGET_NAME_TESTS: list[CommandTestCase] = ( + COLLMOD_TARGET_NAME_RESOLUTION_TESTS + + COLLMOD_TARGET_NAME_WIRING_ERROR_TESTS + + COLLMOD_TARGET_TYPE_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_TARGET_NAME_TESTS)) +def test_collMod_target_name(database_client, collection, test): + """Test collMod target name resolution, no-op success, and validation wiring.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_time_series_bucketing.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_time_series_bucketing.py new file mode 100644 index 000000000..54280422b --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_time_series_bucketing.py @@ -0,0 +1,369 @@ +"""Tests for collMod time series bucketing (bucketRoundingSeconds / bucketMaxSpanSeconds).""" + +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, + INVALID_OPTIONS_ERROR, + 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 +from documentdb_tests.framework.target_collection import ( + TimeseriesCollection, + TimeseriesCustomBucketCollection, +) +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + FLOAT_INFINITY, + FLOAT_NAN, + INT64_MAX, +) + +# Property [Bucketing Numeric Type Coercion]: the coupled `bucketRoundingSeconds` +# and `bucketMaxSpanSeconds` accept any numeric type. +COLLMOD_TS_BUCKET_NUMERIC_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "numeric_int32", + target_collection=TimeseriesCustomBucketCollection(bucket_seconds=100), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "timeseries": {"bucketRoundingSeconds": 200, "bucketMaxSpanSeconds": 200}, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept int32 bucketing seconds", + ), + CommandTestCase( + "numeric_int64", + target_collection=TimeseriesCustomBucketCollection(bucket_seconds=100), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "timeseries": { + "bucketRoundingSeconds": Int64(200), + "bucketMaxSpanSeconds": Int64(200), + }, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept int64 bucketing seconds", + ), + CommandTestCase( + "numeric_double", + target_collection=TimeseriesCustomBucketCollection(bucket_seconds=100), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "timeseries": {"bucketRoundingSeconds": 200.0, "bucketMaxSpanSeconds": 200.0}, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept double bucketing seconds", + ), + CommandTestCase( + "numeric_decimal", + target_collection=TimeseriesCustomBucketCollection(bucket_seconds=100), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "timeseries": { + "bucketRoundingSeconds": Decimal128("200"), + "bucketMaxSpanSeconds": Decimal128("200"), + }, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept decimal128 bucketing seconds", + ), +] + +# Property [Bucketing Equality After Truncation]: the two coupled fields must be +# equal, and equality is checked after truncation toward zero, so distinct +# fractional inputs that truncate to the same integer are accepted as equal. +COLLMOD_TS_BUCKET_EQUALITY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "equal_after_truncation", + target_collection=TimeseriesCustomBucketCollection(bucket_seconds=100), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "timeseries": { + "bucketRoundingSeconds": 200.9, + "bucketMaxSpanSeconds": 200.1, + }, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept bucketing seconds that are equal only after truncation", + ), +] + +# Property [Bucketing Range Boundaries]: the lower and upper boundary values +# are both accepted (the range is inclusive at both ends). +COLLMOD_TS_BUCKET_BOUNDARY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "lower_boundary_1", + target_collection=TimeseriesCustomBucketCollection(bucket_seconds=1), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "timeseries": {"bucketRoundingSeconds": 1, "bucketMaxSpanSeconds": 1}, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept the lower boundary bucketing seconds of 1", + ), + CommandTestCase( + "upper_boundary_31536000", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "timeseries": { + "bucketRoundingSeconds": 31_536_000, + "bucketMaxSpanSeconds": 31_536_000, + }, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept the upper boundary bucketing seconds of 31536000", + ), +] + +# Property [Bucketing Increase-Only Inclusive]: a new bucketing value greater +# than or equal to the existing/implied value is accepted, including a new value +# equal to the value implied by the seconds granularity. +COLLMOD_TS_BUCKET_INCREASE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "equal_implied_3600", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "timeseries": {"bucketRoundingSeconds": 3600, "bucketMaxSpanSeconds": 3600}, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a new bucketing value equal to the implied 3600", + ), +] + +# Property [Bucketing Null No-Op]: a null value for both coupled fields is an +# accepted no-op. +COLLMOD_TS_BUCKET_NULL_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "null_both", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "timeseries": {"bucketRoundingSeconds": None, "bucketMaxSpanSeconds": None}, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept null bucketing seconds as a no-op", + ), +] + +# Property [Bucketing Type Rejection]: a non-numeric bucketing value is rejected +# with a TypeMismatch error, and an array is not unwrapped into a numeric value. +COLLMOD_TS_BUCKET_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"bucket_type_{tid}", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "timeseries": {"bucketRoundingSeconds": v, "bucketMaxSpanSeconds": v}, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} bucketing value as the wrong type", + ) + for tid, val in [ + ("string", "x"), + ("bool_true", True), + ("bool_false", False), + ("array", [1]), + ("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 [Bucketing Below-Minimum Rejection]: a bucketing value below the +# minimum after truncation toward zero is rejected with a BadValue error, +# including values that coerce to zero (a fraction truncates, NaN coerces). +COLLMOD_TS_BUCKET_BELOW_MIN_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"bucket_below_min_{nid}", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "timeseries": {"bucketRoundingSeconds": v, "bucketMaxSpanSeconds": v}, + }, + error_code=BAD_VALUE_ERROR, + msg=f"collMod should reject a {nid} bucketing value as below the minimum of 1", + ) + for nid, val in [ + ("zero", 0), + ("negative", -1), + ("half", 0.5), + ("nan_float", FLOAT_NAN), + ("nan_decimal", DECIMAL128_NAN), + ] +] + +# Property [Bucketing Above-Maximum Rejection]: a bucketing value above the +# maximum after coercion is rejected with a BadValue error, including infinite and +# int64-max inputs that clamp to int32 max and still exceed the ceiling. +COLLMOD_TS_BUCKET_ABOVE_MAX_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"bucket_above_max_{nid}", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "timeseries": {"bucketRoundingSeconds": v, "bucketMaxSpanSeconds": v}, + }, + error_code=BAD_VALUE_ERROR, + msg=f"collMod should reject a {nid} bucketing value as above the maximum of 31536000", + ) + for nid, val in [ + ("just_above", 31_536_001), + ("float_infinity", FLOAT_INFINITY), + ("decimal_infinity", DECIMAL128_INFINITY), + ("int64_max", INT64_MAX), + ] +] + +# Property [Bucketing Coupling Rejection]: setting only one of the two coupled +# fields, or setting them to values that are unequal after truncation, is +# rejected with an InvalidOptions error. +COLLMOD_TS_BUCKET_COUPLING_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "only_max_span", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "timeseries": {"bucketMaxSpanSeconds": 3600}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject setting only bucketMaxSpanSeconds without bucketRoundingSeconds", + ), + CommandTestCase( + "only_rounding", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "timeseries": {"bucketRoundingSeconds": 3600}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject setting only bucketRoundingSeconds without bucketMaxSpanSeconds", + ), + CommandTestCase( + "unequal_after_truncation", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "timeseries": { + "bucketRoundingSeconds": 3600.9, + "bucketMaxSpanSeconds": 3601.1, + }, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject coupled bucketing values that are unequal after truncation", + ), +] + +# Property [Bucketing Decrease Rejection]: a new bucketing value less than the +# existing/implied bucketMaxSpanSeconds is rejected with an InvalidOptions +# error, with the value implied by the seconds granularity as the threshold. +COLLMOD_TS_BUCKET_DECREASE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "below_implied_3600", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "timeseries": {"bucketRoundingSeconds": 3599, "bucketMaxSpanSeconds": 3599}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject a new bucketing value below the implied 3600", + ), +] + +# Property [Bucketing With Granularity Rejection]: combining granularity with a +# custom bucketing value that differs from the granularity's default in the same +# timeseries document is rejected with an InvalidOptions error. +COLLMOD_TS_BUCKET_WITH_GRANULARITY_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "granularity_and_both", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "timeseries": { + "granularity": "seconds", + "bucketRoundingSeconds": 7200, + "bucketMaxSpanSeconds": 7200, + }, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject granularity combined with non-default bucketing fields", + ), +] + +COLLMOD_TS_BUCKETING_TESTS: list[CommandTestCase] = ( + COLLMOD_TS_BUCKET_NUMERIC_TESTS + + COLLMOD_TS_BUCKET_EQUALITY_TESTS + + COLLMOD_TS_BUCKET_BOUNDARY_TESTS + + COLLMOD_TS_BUCKET_INCREASE_TESTS + + COLLMOD_TS_BUCKET_NULL_TESTS + + COLLMOD_TS_BUCKET_TYPE_ERROR_TESTS + + COLLMOD_TS_BUCKET_BELOW_MIN_ERROR_TESTS + + COLLMOD_TS_BUCKET_ABOVE_MAX_ERROR_TESTS + + COLLMOD_TS_BUCKET_COUPLING_ERROR_TESTS + + COLLMOD_TS_BUCKET_DECREASE_ERROR_TESTS + + COLLMOD_TS_BUCKET_WITH_GRANULARITY_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_TS_BUCKETING_TESTS)) +def test_collMod_time_series_bucketing(database_client, collection, test): + """Test collMod time series bucketing acceptance and rejection.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_time_series_document.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_time_series_document.py new file mode 100644 index 000000000..c2316e6cb --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_time_series_document.py @@ -0,0 +1,171 @@ +"""Tests for collMod time series document acceptance and rejection.""" + +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, + UNRECOGNIZED_COMMAND_FIELD_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.target_collection import ( + CappedCollection, + ClusteredCollection, + TimeseriesCollection, + ViewCollection, +) + +# Property [Timeseries Document Acceptance]: on a time series collection the +# `timeseries` document is accepted, with the empty document and null both +# treated as no-ops. +COLLMOD_TS_DOC_SUCCESS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "empty_document", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "timeseries": {}}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept an empty timeseries document as a no-op", + ), + CommandTestCase( + "null_document", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "timeseries": None}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a null timeseries document as a no-op", + ), +] + +# Property [Timeseries Type Rejection]: a non-object timeseries value is +# rejected with a TypeMismatch error, and an array is not unwrapped into a +# document. +COLLMOD_TS_DOC_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"doc_type_{tid}", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx, v=val: {"collMod": ctx.collection, "timeseries": v}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} timeseries value as the wrong type", + ) + for tid, val in [ + ("string", "x"), + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.0), + ("decimal", Decimal128("1")), + ("bool_true", True), + ("bool_false", False), + ("array", [{"granularity": "seconds"}]), + ("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 [Timeseries Unknown Sub-Field Rejection]: an unrecognized sub-field +# inside the timeseries document is rejected, including the creation-only +# timeField and metaField sub-fields that are not modifiable through collMod. +COLLMOD_TS_DOC_UNKNOWN_FIELD_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"unknown_{fid}", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx, sub=subdoc: {"collMod": ctx.collection, "timeseries": sub}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg=f"collMod should reject an unknown {fid} sub-field in the timeseries document", + ) + for fid, subdoc in [ + ("arbitrary", {"bogus": 1}), + ("time_field", {"timeField": "ts"}), + ("meta_field", {"metaField": "meta"}), + ] +] + +# Property [Timeseries Unsupported Target Rejection]: applying the timeseries +# document to a non-time-series collection (regular, capped, clustered, or view) +# is rejected because the option is only supported on time series collections. +COLLMOD_TS_DOC_TARGET_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "doc_target_regular", + docs=[{"_id": 1}], + command=lambda ctx: {"collMod": ctx.collection, "timeseries": {}}, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject a timeseries document on a regular collection", + ), + CommandTestCase( + "target_capped", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "timeseries": {}}, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject a timeseries document on a capped collection", + ), + CommandTestCase( + "target_clustered", + target_collection=ClusteredCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "timeseries": {}}, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject a timeseries document on a clustered collection", + ), + CommandTestCase( + "doc_target_view", + target_collection=ViewCollection(options={"pipeline": []}), + docs=[{"_id": 1}], + command=lambda ctx: {"collMod": ctx.collection, "timeseries": {}}, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject a timeseries document on a view", + ), +] + +COLLMOD_TS_DOCUMENT_TESTS: list[CommandTestCase] = ( + COLLMOD_TS_DOC_SUCCESS_TESTS + + COLLMOD_TS_DOC_TYPE_ERROR_TESTS + + COLLMOD_TS_DOC_UNKNOWN_FIELD_ERROR_TESTS + + COLLMOD_TS_DOC_TARGET_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_TS_DOCUMENT_TESTS)) +def test_collMod_time_series_document(database_client, collection, test): + """Test collMod time series document acceptance and rejection.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_time_series_expire.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_time_series_expire.py new file mode 100644 index 000000000..a2f3f294f --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_time_series_expire.py @@ -0,0 +1,371 @@ +"""Tests for collMod top-level expireAfterSeconds on time series collections.""" + +from __future__ import annotations + +import time +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 +from documentdb_tests.framework.property_checks import Eq +from documentdb_tests.framework.target_collection import ( + ClusteredCollection, + TimeseriesTTLCollection, + ViewCollection, +) +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_HALF, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, +) + +# The time series TTL ceiling is the current epoch seconds: a value at or below +# now is accepted, a value above it is rejected. The tests offset from now by +# this margin in each direction so they do not assume the test runner and server +# clocks match to the second. It is generous enough to absorb realistic clock +# skew plus command latency, while still bracketing the ceiling close enough to +# now that a fixed-constant ceiling could not stay inside the window across runs. +_EPOCH_MARGIN_SECONDS = 60 + +# Property [Clear TTL]: the exact lowercase string "off" clears the TTL and is +# accepted on both time series and clustered collections. +COLLMOD_TS_EXPIRE_OFF_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "off_timeseries", + target_collection=TimeseriesTTLCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "expireAfterSeconds": "off"}, + expected={"ok": Eq(1.0)}, + msg="collMod should clear the TTL with 'off' on a time series collection", + ), + CommandTestCase( + "off_clustered", + target_collection=ClusteredCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "expireAfterSeconds": "off"}, + expected={"ok": Eq(1.0)}, + msg="collMod should clear the TTL with 'off' on a clustered collection", + ), +] + +# Property [Numeric Type Acceptance]: any numeric type is accepted despite the +# declared [string, long] type, with the value set as the TTL on both time +# series and clustered collections. +COLLMOD_TS_EXPIRE_NUMERIC_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "numeric_int32_timeseries", + target_collection=TimeseriesTTLCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "expireAfterSeconds": 100}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept an int32 expireAfterSeconds on a time series collection", + ), + CommandTestCase( + "numeric_int64_timeseries", + target_collection=TimeseriesTTLCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "expireAfterSeconds": Int64(100)}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept an int64 expireAfterSeconds on a time series collection", + ), + CommandTestCase( + "numeric_double_timeseries", + target_collection=TimeseriesTTLCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "expireAfterSeconds": 100.0}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a double expireAfterSeconds on a time series collection", + ), + CommandTestCase( + "numeric_decimal_timeseries", + target_collection=TimeseriesTTLCollection(), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "expireAfterSeconds": Decimal128("100"), + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a decimal128 expireAfterSeconds on a time series collection", + ), + CommandTestCase( + "numeric_int32_clustered", + target_collection=ClusteredCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "expireAfterSeconds": 100}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept an int32 expireAfterSeconds on a clustered collection", + ), +] + +# Property [Positive Fractional Acceptance]: a positive fractional value is +# accepted (the command response does not echo the coerced/stored value). +COLLMOD_TS_EXPIRE_POSITIVE_FRACTIONAL_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "positive_fractional_double", + target_collection=TimeseriesTTLCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "expireAfterSeconds": 100.9}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a positive fractional expireAfterSeconds", + ), + CommandTestCase( + "positive_fractional_decimal", + target_collection=TimeseriesTTLCollection(), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "expireAfterSeconds": Decimal128("100.9"), + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a positive fractional decimal128 expireAfterSeconds", + ), +] + +# Property [Negative-to-Zero Acceptance]: a negative value that truncates to 0 +# is accepted, exercising the near-boundary partner to the error property where +# a value truncating to <= -1 is rejected. +COLLMOD_TS_EXPIRE_NEGATIVE_ZERO_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "negative_double_near_one", + target_collection=TimeseriesTTLCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "expireAfterSeconds": -0.9}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a negative double expireAfterSeconds just above -1 as 0", + ), + CommandTestCase( + "negative_decimal", + target_collection=TimeseriesTTLCollection(), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "expireAfterSeconds": DECIMAL128_NEGATIVE_HALF, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a negative decimal expireAfterSeconds as 0", + ), +] + +# Property [NaN Coercion]: a NaN value (float or decimal) is coerced to 0 and +# accepted rather than rejected. +COLLMOD_TS_EXPIRE_NAN_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "nan_float", + target_collection=TimeseriesTTLCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "expireAfterSeconds": FLOAT_NAN}, + expected={"ok": Eq(1.0)}, + msg="collMod should coerce a float NaN expireAfterSeconds to 0", + ), + CommandTestCase( + "nan_decimal", + target_collection=TimeseriesTTLCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "expireAfterSeconds": DECIMAL128_NAN}, + expected={"ok": Eq(1.0)}, + msg="collMod should coerce a decimal NaN expireAfterSeconds to 0", + ), +] + +# Property [Epoch Acceptance on Time Series]: a value just below the current +# wall-clock epoch seconds is accepted on a time series collection (the accepted +# partner to the rejection of values above now). +COLLMOD_TS_EXPIRE_EPOCH_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "epoch_just_below_now", + target_collection=TimeseriesTTLCollection(), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "expireAfterSeconds": int(time.time()) - _EPOCH_MARGIN_SECONDS, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept an expireAfterSeconds just below the current epoch seconds " + "on a time series collection", + ), +] + +# Property [Type Rejection]: a top-level expireAfterSeconds value whose type is +# outside the accepted string and numeric types produces a TypeMismatch error. +COLLMOD_TS_EXPIRE_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"expire_type_{tid}", + target_collection=TimeseriesTTLCollection(), + docs=[], + command=lambda ctx, v=val: {"collMod": ctx.collection, "expireAfterSeconds": v}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} expireAfterSeconds as the wrong type", + ) + for tid, val in [ + ("null", None), + ("bool_true", True), + ("bool_false", False), + ("array", [100]), + ("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 [Invalid String Rejection]: any string other than the exact lowercase +# "off" is rejected, including case variants and whitespace-padded forms. +COLLMOD_TS_EXPIRE_STRING_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"string_{sid}", + target_collection=TimeseriesTTLCollection(), + docs=[], + command=lambda ctx, v=val: {"collMod": ctx.collection, "expireAfterSeconds": v}, + error_code=INVALID_OPTIONS_ERROR, + msg=f"collMod should reject the {sid} string expireAfterSeconds", + ) + for sid, val in [ + ("title_case", "Off"), + ("upper_case", "OFF"), + ("empty", ""), + ("on", "on"), + ("trailing_space", "off "), + ("leading_space", " off"), + ] +] + +# Property [Below-Zero Rejection]: a numeric value that truncates to <= -1 is +# rejected ("cannot be less than 0"), including -Infinity which is rejected as +# below zero rather than as an overflow. +COLLMOD_TS_EXPIRE_BELOW_ZERO_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"below_zero_{nid}", + target_collection=TimeseriesTTLCollection(), + docs=[], + command=lambda ctx, v=val: {"collMod": ctx.collection, "expireAfterSeconds": v}, + error_code=INVALID_OPTIONS_ERROR, + msg=f"collMod should reject a {nid} expireAfterSeconds as below zero", + ) + for nid, val in [ + ("int", -1), + ("double", -1.0), + ("decimal", Decimal128("-1")), + ("float_negative_infinity", FLOAT_NEGATIVE_INFINITY), + ] +] + +# Property [Overflow Rejection]: a value that overflows the int64 milliseconds +# conversion is rejected as out of int64 range. +COLLMOD_TS_EXPIRE_OVERFLOW_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"overflow_{oid}", + target_collection=TimeseriesTTLCollection(), + docs=[], + command=lambda ctx, v=val: {"collMod": ctx.collection, "expireAfterSeconds": v}, + error_code=INVALID_OPTIONS_ERROR, + msg=f"collMod should reject a {oid} expireAfterSeconds as out of int64 range", + ) + for oid, val in [ + ("float_infinity", FLOAT_INFINITY), + ("decimal_infinity", DECIMAL128_INFINITY), + # The smallest int64 whose conversion to milliseconds (the engine's + # `* 1000` cast) overflows int64, exercising the overflow rejection + # path distinct from the simpler out-of-range checks. + ("int64_millis", Int64(9223372036854776)), + ] +] + +# Property [Epoch Ceiling on Time Series]: on a time series collection, a value +# above the current wall-clock epoch seconds is rejected (the rejected partner +# to the acceptance of values below now). +COLLMOD_TS_EXPIRE_EPOCH_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "epoch_above_now", + target_collection=TimeseriesTTLCollection(), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "expireAfterSeconds": int(time.time()) + _EPOCH_MARGIN_SECONDS, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject an expireAfterSeconds above the current epoch seconds on a " + "time series collection", + ), +] + +# Property [Unsupported Target Rejection]: a top-level expireAfterSeconds applied +# to a regular collection or a view is rejected because the option is only +# supported on collections clustered by _id. +COLLMOD_TS_EXPIRE_TARGET_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "expire_target_regular", + docs=[{"_id": 1}], + command=lambda ctx: {"collMod": ctx.collection, "expireAfterSeconds": 100}, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject a top-level expireAfterSeconds on a regular collection", + ), + CommandTestCase( + "expire_target_view", + target_collection=ViewCollection(options={"pipeline": []}), + docs=[{"_id": 1}], + command=lambda ctx: {"collMod": ctx.collection, "expireAfterSeconds": 100}, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject a top-level expireAfterSeconds on a view", + ), +] + +COLLMOD_TS_EXPIRE_TESTS: list[CommandTestCase] = ( + COLLMOD_TS_EXPIRE_OFF_TESTS + + COLLMOD_TS_EXPIRE_NUMERIC_TESTS + + COLLMOD_TS_EXPIRE_POSITIVE_FRACTIONAL_TESTS + + COLLMOD_TS_EXPIRE_NEGATIVE_ZERO_TESTS + + COLLMOD_TS_EXPIRE_NAN_TESTS + + COLLMOD_TS_EXPIRE_EPOCH_TESTS + + COLLMOD_TS_EXPIRE_TYPE_ERROR_TESTS + + COLLMOD_TS_EXPIRE_STRING_ERROR_TESTS + + COLLMOD_TS_EXPIRE_BELOW_ZERO_ERROR_TESTS + + COLLMOD_TS_EXPIRE_OVERFLOW_ERROR_TESTS + + COLLMOD_TS_EXPIRE_EPOCH_ERROR_TESTS + + COLLMOD_TS_EXPIRE_TARGET_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_TS_EXPIRE_TESTS)) +def test_collMod_time_series_expire(database_client, collection, test): + """Test collMod top-level expireAfterSeconds acceptance and rejection on time series.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_time_series_granularity.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_time_series_granularity.py new file mode 100644 index 000000000..54f40bd7d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_time_series_granularity.py @@ -0,0 +1,190 @@ +"""Tests for collMod time series granularity.""" + +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, + INVALID_OPTIONS_ERROR, + 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 +from documentdb_tests.framework.target_collection import ( + TimeseriesCollection, +) + +# Property [Granularity Enum Acceptance]: a granularity equal to one of the +# valid enum strings ("seconds", "minutes", "hours") is accepted, and a null +# granularity is an accepted no-op. +COLLMOD_TS_GRANULARITY_ENUM_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"enum_{gid}", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx, v=val: {"collMod": ctx.collection, "timeseries": {"granularity": v}}, + expected={"ok": Eq(1.0)}, + msg=f"collMod should accept a {gid} granularity", + ) + for gid, val in [ + ("seconds", "seconds"), + ("minutes", "minutes"), + ("hours", "hours"), + ("null", None), + ] +] + +# Property [Granularity Increase Transition]: a granularity change that +# increases or holds the granularity from a non-default starting granularity is +# accepted, starting from a collection already created at the prior granularity. +COLLMOD_TS_GRANULARITY_TRANSITION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "transition_minutes_to_hours", + target_collection=TimeseriesCollection( + timeseries_options={"timeField": "ts", "metaField": "meta", "granularity": "minutes"} + ), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "timeseries": {"granularity": "hours"}, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a granularity increase from minutes to hours", + ), + CommandTestCase( + "transition_hours_same_value", + target_collection=TimeseriesCollection( + timeseries_options={"timeField": "ts", "metaField": "meta", "granularity": "hours"} + ), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "timeseries": {"granularity": "hours"}, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a same-value hours granularity as a no-op", + ), +] + +# Property [Granularity Type Rejection]: a non-string granularity value is +# rejected with a TypeMismatch error. +COLLMOD_TS_GRANULARITY_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"granularity_type_{tid}", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "timeseries": {"granularity": v}, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} granularity as the wrong type", + ) + for tid, val in [ + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.0), + ("decimal", Decimal128("1")), + ("bool_true", True), + ("bool_false", False), + ("array", ["seconds"]), + ("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 [Granularity Enum Rejection]: the granularity enum is case-sensitive +# and accepts only the exact lowercase seconds, minutes, or hours, so any other +# string (case variants, near-misses, the empty string, and an oversized +# invalid string) produces a BadValue error rather than a string-size error. +COLLMOD_TS_GRANULARITY_ENUM_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"granularity_enum_{tid}", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "timeseries": {"granularity": v}, + }, + error_code=BAD_VALUE_ERROR, + msg=f"collMod should reject {tid} as a granularity enum value", + ) + for tid, val in [ + ("empty", ""), + ("capitalized", "Seconds"), + ("uppercase", "SECONDS"), + ("singular", "second"), + ("arbitrary", "days"), + ("large_invalid", "x" * 16_000_000), + ] +] + +# Property [Granularity Decrease Rejection]: a granularity change that decreases +# the granularity is rejected as an invalid transition, starting from a +# collection already created at a higher granularity. +COLLMOD_TS_GRANULARITY_DECREASE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "granularity_decrease_hours_to_minutes", + target_collection=TimeseriesCollection( + timeseries_options={"timeField": "ts", "metaField": "meta", "granularity": "hours"} + ), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "timeseries": {"granularity": "minutes"}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject a granularity decrease from hours to minutes", + ), +] + +COLLMOD_TS_GRANULARITY_TESTS: list[CommandTestCase] = ( + COLLMOD_TS_GRANULARITY_ENUM_TESTS + + COLLMOD_TS_GRANULARITY_TRANSITION_TESTS + + COLLMOD_TS_GRANULARITY_TYPE_ERROR_TESTS + + COLLMOD_TS_GRANULARITY_ENUM_ERROR_TESTS + + COLLMOD_TS_GRANULARITY_DECREASE_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_TS_GRANULARITY_TESTS)) +def test_collMod_time_series_granularity(database_client, collection, test): + """Test collMod time series granularity acceptance and rejection.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_validation_level_action.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_validation_level_action.py new file mode 100644 index 000000000..f44ee5c71 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_validation_level_action.py @@ -0,0 +1,254 @@ +"""Tests for collMod validationLevel and validationAction.""" + +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 ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + INVALID_OPTIONS_ERROR, + 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 +from documentdb_tests.framework.target_collection import ( + TimeseriesCollection, + ViewCollection, +) +from documentdb_tests.framework.test_constants import ( + DECIMAL128_ONE_AND_HALF, +) + +# Property [validationLevel Success]: a validationLevel equal to off, strict, +# or moderate is accepted, and null is accepted as a no-op. +COLLMOD_VALIDATION_LEVEL_SUCCESS_TESTS: list[CommandTestCase] = [ + *[ + CommandTestCase( + f"level_{lvl}", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=lvl: {"collMod": ctx.collection, "validationLevel": v}, + expected={"ok": Eq(1.0)}, + msg=f"collMod should accept the {lvl} validationLevel", + ) + for lvl in ["off", "strict", "moderate"] + ], + CommandTestCase( + "level_null", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "validationLevel": None}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a null validationLevel as an omitted field", + ), +] + +# Property [validationLevel Type Rejection]: a validationLevel value that is +# neither a string nor null produces a TypeMismatch error. +COLLMOD_VALIDATION_LEVEL_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"level_type_{tid}", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=val: {"collMod": ctx.collection, "validationLevel": v}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} validationLevel as a non-string", + ) + for tid, val in [ + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool_true", True), + ("bool_false", False), + ("array", ["strict"]), + ("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 [validationLevel Enum Rejection]: the validationLevel enum is +# case-sensitive and applies no whitespace trimming, so any string other than +# the exact lowercase off, strict, or moderate produces a BadValue error, and a +# dollar-prefixed string is rejected as a literal value rather than a field path +# or variable reference. +COLLMOD_VALIDATION_LEVEL_ENUM_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"level_enum_{tid}", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=val: {"collMod": ctx.collection, "validationLevel": v}, + error_code=BAD_VALUE_ERROR, + msg=f"collMod should reject {tid} as a validationLevel enum value", + ) + for tid, val in [ + ("empty", ""), + ("arbitrary", "nope"), + ("capitalized_strict", "Strict"), + ("uppercase_off", "OFF"), + ("leading_space", " strict"), + ("trailing_space", "strict "), + ("embedded_space", "str ict"), + ("nbsp", "strict\u00a0"), + ("dollar", "$"), + ("dollar_dollar", "$$"), + ("large_invalid", "x" * 16_000_000), + ] +] + +# Property [validationLevel Unsupported Collection Type Rejection]: a +# validationLevel applied to a collection type that does not support validation +# (a view or a time series collection) is rejected. +COLLMOD_VALIDATION_LEVEL_UNSUPPORTED_TARGET_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"level_unsupported_{target_id}", + docs=[], + target_collection=target, + command=lambda ctx: {"collMod": ctx.collection, "validationLevel": "strict"}, + error_code=INVALID_OPTIONS_ERROR, + msg=f"collMod should reject a validationLevel on a {target_id}", + ) + for target_id, target in [ + ("view", ViewCollection()), + ("timeseries", TimeseriesCollection()), + ] +] + +# Property [validationAction Success]: a validationAction equal to error, warn, +# or errorAndLog is accepted, and null is accepted as a no-op. +COLLMOD_VALIDATION_ACTION_SUCCESS_TESTS: list[CommandTestCase] = [ + *[ + CommandTestCase( + f"action_{action}", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=action: {"collMod": ctx.collection, "validationAction": v}, + expected={"ok": Eq(1.0)}, + msg=f"collMod should accept the {action} validationAction", + ) + for action in ["error", "warn", "errorAndLog"] + ], + CommandTestCase( + "action_null", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "validationAction": None}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a null validationAction as an omitted field", + ), +] + +# Property [validationAction Type Rejection]: a validationAction value that is +# neither a string nor null produces a TypeMismatch error. +COLLMOD_VALIDATION_ACTION_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"action_type_{tid}", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=val: {"collMod": ctx.collection, "validationAction": v}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} validationAction as a non-string", + ) + for tid, val in [ + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool_true", True), + ("bool_false", False), + ("array", ["error"]), + ("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 [validationAction Enum Rejection]: the validationAction enum is +# case-sensitive and applies no whitespace trimming, so any string other than +# the exact lowercase error, warn, or errorAndLog produces a BadValue error, and +# a dollar-prefixed string is rejected as a literal value rather than a field +# path or variable reference. +COLLMOD_VALIDATION_ACTION_ENUM_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"action_enum_{tid}", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=val: {"collMod": ctx.collection, "validationAction": v}, + error_code=BAD_VALUE_ERROR, + msg=f"collMod should reject {tid} as a validationAction enum value", + ) + for tid, val in [ + ("empty", ""), + ("arbitrary", "nope"), + ("capitalized_error", "Error"), + ("uppercase_warn", "WARN"), + ("leading_space", " error"), + ("trailing_space", "error "), + ("embedded_space", "err or"), + ("nbsp", "error\u00a0"), + ("dollar", "$"), + ("dollar_dollar", "$$"), + ("large_invalid", "x" * 16_000_000), + ] +] + +# Property [validationAction Unsupported Collection Type Rejection]: a +# validationAction applied to a collection type that does not support validation +# (a view or a time series collection) is rejected. +COLLMOD_VALIDATION_ACTION_UNSUPPORTED_TARGET_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"action_unsupported_{target_id}", + docs=[], + target_collection=target, + command=lambda ctx: {"collMod": ctx.collection, "validationAction": "error"}, + error_code=INVALID_OPTIONS_ERROR, + msg=f"collMod should reject a validationAction on a {target_id}", + ) + for target_id, target in [ + ("view", ViewCollection()), + ("timeseries", TimeseriesCollection()), + ] +] + +COLLMOD_VALIDATION_LEVEL_ACTION_TESTS: list[CommandTestCase] = ( + COLLMOD_VALIDATION_LEVEL_SUCCESS_TESTS + + COLLMOD_VALIDATION_LEVEL_TYPE_ERROR_TESTS + + COLLMOD_VALIDATION_LEVEL_ENUM_ERROR_TESTS + + COLLMOD_VALIDATION_LEVEL_UNSUPPORTED_TARGET_ERROR_TESTS + + COLLMOD_VALIDATION_ACTION_SUCCESS_TESTS + + COLLMOD_VALIDATION_ACTION_TYPE_ERROR_TESTS + + COLLMOD_VALIDATION_ACTION_ENUM_ERROR_TESTS + + COLLMOD_VALIDATION_ACTION_UNSUPPORTED_TARGET_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_VALIDATION_LEVEL_ACTION_TESTS)) +def test_collMod_validation_level_action(database_client, collection, register_db_cleanup, test): + """Test collMod validationLevel and validationAction acceptance and rejection.""" + collection = test.prepare(database_client, collection) + if collection.database.name != database_client.name: + register_db_cleanup(f"{collection.database.name}.{collection.name}") + 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/collections/commands/collMod/test_collMod_validator.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_validator.py new file mode 100644 index 000000000..178e3aa7a --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_validator.py @@ -0,0 +1,447 @@ +"""Tests for collMod validator.""" + +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 ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + FAILED_TO_PARSE_ERROR, + INVALID_OPTIONS_ERROR, + NEAR_NOT_ALLOWED_ERROR, + REGEX_COMPILE_ERROR, + TYPE_MISMATCH_ERROR, + UNRECOGNIZED_EXPRESSION_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.target_collection import ( + ExistingDatabase, + SystemViewsCollection, + TimeseriesCollection, + ValidatedCollection, + ViewCollection, +) +from documentdb_tests.framework.test_constants import ( + DECIMAL128_ONE_AND_HALF, + REGEX_PATTERN_LIMIT_BYTES, +) + +# Property [validator Success]: a validator value that is an object (including +# the empty document) or null (treated as omitted) is accepted, and any object +# expressing a well-formed match query, a valid $expr, or a valid $jsonSchema is +# accepted as a collection validator. +COLLMOD_VALIDATOR_SUCCESS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "empty_document", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "validator": {}}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept an empty document validator", + ), + CommandTestCase( + "null", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "validator": None}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a null validator as an omitted field", + ), + CommandTestCase( + "field_equality_query", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "validator": {"a": 1}}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a simple field-equality query validator", + ), + CommandTestCase( + "dotted_path_query", + docs=[{"_id": 1, "a": {"b": 1}}], + command=lambda ctx: {"collMod": ctx.collection, "validator": {"a.b": {"$exists": True}}}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a dotted-path query validator", + ), + CommandTestCase( + "type_operator", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "validator": {"a": {"$type": "int"}}}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a $type operator in a validator", + ), + CommandTestCase( + "mod_operator", + docs=[{"_id": 1, "a": 4}], + command=lambda ctx: {"collMod": ctx.collection, "validator": {"a": {"$mod": [2, 0]}}}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a $mod operator in a validator", + ), + CommandTestCase( + "large_string_value", + docs=[{"_id": 1}], + command=lambda ctx: {"collMod": ctx.collection, "validator": {"a": "x" * 10_000}}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a validator carrying a large string value", + ), + CommandTestCase( + "expr_bare_true", + docs=[{"_id": 1}], + command=lambda ctx: {"collMod": ctx.collection, "validator": {"$expr": True}}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a bare $expr: true validator", + ), + CommandTestCase( + "expr_comparison", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "validator": {"$expr": {"$gt": ["$a", 0]}}, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a $expr comparison validator", + ), + CommandTestCase( + "expr_root_variable", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "validator": {"$expr": {"$eq": ["$$ROOT.a", "$a"]}}, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a $expr validator referencing the $$ROOT variable", + ), + CommandTestCase( + "expr_now_variable", + docs=[{"_id": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "validator": {"$expr": {"$lte": ["$created", "$$NOW"]}}, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a $expr validator referencing the $$NOW variable", + ), + CommandTestCase( + "json_schema_valid_bson_type", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "validator": {"$jsonSchema": {"properties": {"a": {"bsonType": "int"}}}}, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a $jsonSchema validator with a valid bsonType", + ), + CommandTestCase( + "regex_pattern_at_limit", + docs=[{"_id": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "validator": {"a": {"$regex": "a" * REGEX_PATTERN_LIMIT_BYTES}}, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a validator regex pattern at the 16384-byte limit", + ), +] + +# Property [validator No Retroactive Validation]: setting a validator does not +# validate documents already present in the collection, so the command succeeds +# (ok:1.0) even when an existing document would fail the newly-set validator, +# including under an explicit strict/error validation mode. +COLLMOD_VALIDATOR_NO_RETROACTIVE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "json_schema_existing_doc_violates", + docs=[{"_id": 1, "a": "not_an_int"}], + command=lambda ctx: { + "collMod": ctx.collection, + "validator": {"$jsonSchema": {"properties": {"a": {"bsonType": "int"}}}}, + "validationLevel": "strict", + "validationAction": "error", + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a $jsonSchema validator even when an existing document " + "violates it, since existing documents are not validated retroactively", + ), +] + +# Property [validator Clears Previously-Set Validator]: an empty {} validator +# applied to a collection that already has a validator is accepted (ok:1.0), +# resetting it to no validation. +COLLMOD_VALIDATOR_CLEAR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "empty_clears_existing_validator", + target_collection=ValidatedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "validator": {}}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept an empty {} validator that clears a previously-set validator", + ), +] + +# Property [validator Type Rejection]: a validator value that is neither an +# object nor null produces a TypeMismatch error. +COLLMOD_VALIDATOR_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"type_{tid}", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=val: {"collMod": ctx.collection, "validator": v}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} validator as a non-object", + ) + for tid, val in [ + ("string", "not_an_object"), + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool_true", True), + ("bool_false", False), + ("array", [{"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 [validator Query Operator Rejection]: query operators that cannot be +# used in a collection validator are rejected, with the error code determined by +# the specific operator. +COLLMOD_VALIDATOR_OPERATOR_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "where_operator", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "validator": {"$where": "true"}}, + error_code=BAD_VALUE_ERROR, + msg="collMod should reject a $where operator in a validator", + ), + CommandTestCase( + "text_operator", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "validator": {"$text": {"$search": "x"}}, + }, + error_code=BAD_VALUE_ERROR, + msg="collMod should reject a $text operator in a validator", + ), + CommandTestCase( + "unknown_dollar_operator", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "validator": {"a": {"$badOp": 1}}}, + error_code=BAD_VALUE_ERROR, + msg="collMod should reject an unknown query operator in a validator", + ), + CommandTestCase( + "near_operator", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "validator": {"a": {"$near": [0, 0]}}, + }, + error_code=NEAR_NOT_ALLOWED_ERROR, + msg="collMod should reject a $near operator in a validator", + ), + CommandTestCase( + "near_sphere_operator", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "validator": {"a": {"$nearSphere": [0, 0]}}, + }, + error_code=NEAR_NOT_ALLOWED_ERROR, + msg="collMod should reject a $nearSphere operator in a validator", + ), + CommandTestCase( + "geo_near_operator", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "validator": {"a": {"$geoNear": [0, 0]}}, + }, + error_code=NEAR_NOT_ALLOWED_ERROR, + msg="collMod should reject a $geoNear operator in a validator", + ), + CommandTestCase( + "expr_unknown_aggregation_operator", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "validator": {"$expr": {"$unknownAggOp": [1, 2]}}, + }, + error_code=UNRECOGNIZED_EXPRESSION_ERROR, + msg="collMod should reject an unknown aggregation operator inside $expr in a validator", + ), +] + +# Property [validator JSON Schema Rejection]: a $jsonSchema with an invalid +# keyword, an invalid type alias, or a conflicting type specification produces a +# FailedToParse error. +COLLMOD_VALIDATOR_JSON_SCHEMA_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "json_schema_unknown_keyword", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "validator": {"$jsonSchema": {"unknownKeyword": 1}}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="collMod should reject a $jsonSchema with an unknown keyword", + ), + CommandTestCase( + "json_schema_type_integer", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "validator": {"$jsonSchema": {"properties": {"a": {"type": "integer"}}}}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="collMod should reject a $jsonSchema using the integer type alias", + ), + CommandTestCase( + "json_schema_type_and_bson_type", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "validator": { + "$jsonSchema": {"properties": {"a": {"type": "string", "bsonType": "int"}}} + }, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="collMod should reject a $jsonSchema specifying both type and bsonType on a property", + ), +] + +# Property [validator Logical Operator Rejection]: a malformed top-level logical +# operator or an unknown dollar-prefixed top-level field produces a BadValue +# error. +COLLMOD_VALIDATOR_LOGICAL_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "empty_and", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "validator": {"$and": []}}, + error_code=BAD_VALUE_ERROR, + msg="collMod should reject an empty $and in a validator", + ), + CommandTestCase( + "empty_or", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "validator": {"$or": []}}, + error_code=BAD_VALUE_ERROR, + msg="collMod should reject an empty $or in a validator", + ), + CommandTestCase( + "non_array_or", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "validator": {"$or": {"a": 1}}}, + error_code=BAD_VALUE_ERROR, + msg="collMod should reject a non-array $or in a validator", + ), + CommandTestCase( + "unknown_dollar_field", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "validator": {"$nope": 1}}, + error_code=BAD_VALUE_ERROR, + msg="collMod should reject an unknown dollar-prefixed top-level field in a validator", + ), +] + +# Property [validator Regex Limit Rejection]: a validator regex pattern one byte +# over the inclusive size limit fails to compile. +COLLMOD_VALIDATOR_REGEX_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "regex_pattern_over_limit", + docs=[{"_id": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "validator": {"a": {"$regex": "a" * (REGEX_PATTERN_LIMIT_BYTES + 1)}}, + }, + error_code=REGEX_COMPILE_ERROR, + msg="collMod should reject a validator regex pattern one byte over the limit", + ), +] + +# Property [validator Restricted Namespace Rejection]: a validator applied to a +# collection in a restricted database (admin, config, local) or to a system.* +# collection is rejected, regardless of the validator's well-formedness. +COLLMOD_VALIDATOR_RESTRICTED_NAMESPACE_ERROR_TESTS: list[CommandTestCase] = [ + *[ + CommandTestCase( + f"restricted_db_{dbname}", + target_collection=ExistingDatabase(db_name=dbname), + # The local database does not support retryable writes, so the + # collection is created empty rather than seeded with documents; its + # existence alone is enough to trigger the validator rejection. + docs=[] if dbname == "local" else [{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "validator": {"a": 1}}, + error_code=INVALID_OPTIONS_ERROR, + msg=f"collMod should reject a validator on a collection in the {dbname} database", + ) + for dbname in ["admin", "config", "local"] + ], + CommandTestCase( + "system_views", + target_collection=SystemViewsCollection(), + command=lambda ctx: {"collMod": ctx.collection, "validator": {"a": 1}}, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject a validator on a system.* collection", + ), +] + +# Property [validator Unsupported Collection Type Rejection]: a validator +# applied to a collection type that does not support validation (a view or a +# time series collection) is rejected. +COLLMOD_VALIDATOR_UNSUPPORTED_TARGET_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"unsupported_{target_id}", + docs=[], + target_collection=target, + command=lambda ctx: {"collMod": ctx.collection, "validator": {"a": 1}}, + error_code=INVALID_OPTIONS_ERROR, + msg=f"collMod should reject a validator on a {target_id}", + ) + for target_id, target in [ + ("view", ViewCollection()), + ("timeseries", TimeseriesCollection()), + ] +] + +COLLMOD_VALIDATOR_TESTS: list[CommandTestCase] = ( + COLLMOD_VALIDATOR_SUCCESS_TESTS + + COLLMOD_VALIDATOR_NO_RETROACTIVE_TESTS + + COLLMOD_VALIDATOR_CLEAR_TESTS + + COLLMOD_VALIDATOR_TYPE_ERROR_TESTS + + COLLMOD_VALIDATOR_OPERATOR_ERROR_TESTS + + COLLMOD_VALIDATOR_JSON_SCHEMA_ERROR_TESTS + + COLLMOD_VALIDATOR_LOGICAL_ERROR_TESTS + + COLLMOD_VALIDATOR_REGEX_ERROR_TESTS + + COLLMOD_VALIDATOR_RESTRICTED_NAMESPACE_ERROR_TESTS + + COLLMOD_VALIDATOR_UNSUPPORTED_TARGET_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_VALIDATOR_TESTS)) +def test_collMod_validator(database_client, collection, register_db_cleanup, test): + """Test collMod validator acceptance and rejection.""" + collection = test.prepare(database_client, collection) + if collection.database.name != database_client.name: + register_db_cleanup(f"{collection.database.name}.{collection.name}") + 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/collections/commands/collMod/test_collMod_view_on.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_view_on.py new file mode 100644 index 000000000..ebfb9cc8d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_view_on.py @@ -0,0 +1,184 @@ +"""Tests for collMod viewOn.""" + +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 ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + GRAPH_CONTAINS_CYCLE_ERROR, + INVALID_NAMESPACE_ERROR, + 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 +from documentdb_tests.framework.target_collection import ViewCollection +from documentdb_tests.framework.test_constants import ( + DECIMAL128_ONE_AND_HALF, + STRING_SIZE_LIMIT_BYTES, +) + +# Property [viewOn Success]: a string viewOn is validated as a namespace but not +# checked for target existence, so any structurally valid name is accepted and +# stored verbatim - including whitespace, control characters, Unicode, and +# interior/trailing dots or database-qualified names - null is accepted as an +# omitted field, and the value has no length limit. (Structurally invalid names +# are rejected by the viewOn Namespace Rejection property.) +COLLMOD_VIEW_ON_SUCCESS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "nonexistent_target", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "viewOn": "no_such_collection"}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a viewOn naming a nonexistent target without validating it", + ), + CommandTestCase( + "null", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "viewOn": None}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a null viewOn as an omitted field", + ), + *[ + CommandTestCase( + f"content_{tid}", + target_collection=ViewCollection(), + command=lambda ctx, v=val: {"collMod": ctx.collection, "viewOn": v}, + expected={"ok": Eq(1.0)}, + msg=f"collMod should accept a viewOn with {tid} content verbatim", + ) + for tid, val in [ + ("single_space", " "), + ("nbsp", "a\u00a0b"), # U+00A0 no-break space. + ("control_char", "\x01"), # U+0001 start of heading. + ("two_byte_unicode", "caf\u00e9"), # U+00E9 latin small e with acute. + ("three_byte_unicode", "\u4e2d"), # U+4E2D CJK ideograph. + ("four_byte_unicode", "\U0001f600coll"), # U+1F600 grinning face. + ("trailing_dot", "trailing."), + ("interior_dots", "a.b.c"), + ("database_qualified", "db.coll"), + ] + ], + *[ + CommandTestCase( + f"length_{tid}", + target_collection=ViewCollection(), + command=lambda ctx, v=val: {"collMod": ctx.collection, "viewOn": v}, + expected={"ok": Eq(1.0)}, + msg=f"collMod should accept a {tid} viewOn value with no length-based limit", + ) + # A viewOn value has no length limit, unlike the collMod target name. + # These three sizes bracket the 16 MB BSON document size to show none of + # them hit a length-based limit. + for tid, val in [ + ("below_16mb", "a" * (STRING_SIZE_LIMIT_BYTES - 1)), + ("at_16mb", "a" * STRING_SIZE_LIMIT_BYTES), + ("above_16mb", "a" * (STRING_SIZE_LIMIT_BYTES + 1)), + ] + ], +] + +# Property [viewOn Type Rejection]: any non-string value for viewOn produces a +# TypeMismatch error. +COLLMOD_VIEW_ON_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"type_{tid}", + target_collection=ViewCollection(), + command=lambda ctx, v=val: {"collMod": ctx.collection, "viewOn": v}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} viewOn as a non-string", + ) + for tid, val in [ + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool_true", True), + ("bool_false", False), + ("array", ["src"]), + ("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 [viewOn Empty Rejection]: an empty string viewOn produces a BadValue +# error. +COLLMOD_VIEW_ON_EMPTY_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "empty", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "viewOn": ""}, + error_code=BAD_VALUE_ERROR, + msg="collMod should reject an empty string viewOn", + ), +] + +# Property [viewOn Namespace Rejection]: a viewOn string that is structurally +# invalid as a namespace produces an InvalidNamespace error, where a leading +# dollar is treated as name content rather than a field path. +COLLMOD_VIEW_ON_NAMESPACE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"namespace_{tid}", + target_collection=ViewCollection(), + command=lambda ctx, v=val: {"collMod": ctx.collection, "viewOn": v}, + error_code=INVALID_NAMESPACE_ERROR, + msg=f"collMod should reject a {tid} viewOn as a structurally invalid namespace", + ) + for tid, val in [ + ("dollar_prefixed", "$x"), + ("embedded_null", "a\x00b"), + ("leading_dot", ".leading"), + ] +] + +# Property [viewOn Self Reference Rejection]: a viewOn equal to the view's own +# name produces a GraphContainsCycle error. +COLLMOD_VIEW_ON_CYCLE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "self_reference", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "viewOn": ctx.collection}, + error_code=GRAPH_CONTAINS_CYCLE_ERROR, + msg="collMod should reject a viewOn equal to the view's own name as a cycle", + ), +] + +COLLMOD_VIEW_ON_TESTS: list[CommandTestCase] = ( + COLLMOD_VIEW_ON_SUCCESS_TESTS + + COLLMOD_VIEW_ON_TYPE_ERROR_TESTS + + COLLMOD_VIEW_ON_EMPTY_ERROR_TESTS + + COLLMOD_VIEW_ON_NAMESPACE_ERROR_TESTS + + COLLMOD_VIEW_ON_CYCLE_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_VIEW_ON_TESTS)) +def test_collMod_view_on(database_client, collection, test): + """Test collMod viewOn acceptance and rejection.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_write_concern.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_write_concern.py new file mode 100644 index 000000000..845b8db7e --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_write_concern.py @@ -0,0 +1,344 @@ +"""Tests for the collMod writeConcern option.""" + +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, + UNRECOGNIZED_COMMAND_FIELD_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_ONE_AND_HALF + +# Property [writeConcern Success]: a top-level writeConcern of null is treated +# as omitted and an empty document is accepted, both succeeding without changing +# the command result. +COLLMOD_WRITE_CONCERN_SUCCESS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "null_treated_as_omitted", + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "writeConcern": None, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should treat a null writeConcern as omitted", + ), + CommandTestCase( + "empty_document", + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "writeConcern": {}, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept an empty writeConcern document", + ), +] + +# Property [writeConcern.w Portable Acceptance]: a number that resolves to 0 or +# 1 (after truncation), the "majority" tag, and an object tag are accepted on +# every topology without changing the command result. +COLLMOD_WRITE_CONCERN_W_SUCCESS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"w_{wid}", + docs=[], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "writeConcern": {"w": v}, + }, + expected={"ok": Eq(1.0)}, + msg=f"collMod should accept a {wid} writeConcern.w value on any topology", + ) + for wid, val in [ + ("int_zero", 0), + ("int_one", 1), + ("double_fractional", 1.5), + ("int64_one", Int64(1)), + ("decimal_one", Decimal128("1")), + ("string_majority", "majority"), + ("object", {"a": 1}), + ] +] + +# Property [writeConcern.w Quorum Acceptance On Replica Set]: a quorum write +# concern (a number above 1, an unrecognized string tag, the empty string, or +# null) is accepted on a replica set, where an unsatisfiable concern surfaces +# asynchronously as a writeConcernError so the command still returns ok:1.0. +COLLMOD_WRITE_CONCERN_W_QUORUM_SUCCESS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"w_quorum_{wid}", + docs=[], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "writeConcern": {"w": v}, + }, + expected={"ok": Eq(1.0)}, + msg=f"collMod should accept a {wid} quorum writeConcern.w on a replica set", + marks=(pytest.mark.requires(quorum_write_concern=True),), + ) + for wid, val in [ + ("int_fifty", 50), + ("decimal_above_one", Decimal128("5")), + ("string_arbitrary", "foo"), + ("string_empty", ""), + ("null", None), + ] +] + +# Property [writeConcern.j Acceptance]: a numeric, bool, or null value is +# accepted for writeConcern.j without changing the command result. +COLLMOD_WRITE_CONCERN_J_SUCCESS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"j_{jid}", + docs=[], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "writeConcern": {"j": v}, + }, + expected={"ok": Eq(1.0)}, + msg=f"collMod should accept a {jid} writeConcern.j value", + ) + for jid, val in [ + ("bool_true", True), + ("bool_false", False), + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("decimal", Decimal128("1")), + ("null", None), + ] +] + +# Property [writeConcern.wtimeout Acceptance]: writeConcern.wtimeout is not +# validated in this context, so a negative number and an arbitrary string are +# both accepted without changing the command result. +COLLMOD_WRITE_CONCERN_WTIMEOUT_SUCCESS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"wtimeout_{wid}", + docs=[], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "writeConcern": {"wtimeout": v}, + }, + expected={"ok": Eq(1.0)}, + msg=f"collMod should accept a {wid} writeConcern.wtimeout value", + ) + for wid, val in [ + ("negative_number", -5), + ("arbitrary_string", "foo"), + ] +] + +# Property [writeConcern.j Type Rejection]: any writeConcern.j value whose type +# is outside the accepted numeric and bool types produces a TypeMismatch error. +COLLMOD_WRITE_CONCERN_J_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"j_type_{tid}", + docs=[], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "writeConcern": {"j": v}, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} writeConcern.j as a non-numeric/bool", + ) + for tid, val in [ + ("string", "x"), + ("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 [writeConcern Type Rejection]: a non-object, non-null top-level +# writeConcern produces a TypeMismatch error. +COLLMOD_WRITE_CONCERN_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"type_{tid}", + docs=[], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "writeConcern": v, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} writeConcern as a non-object", + ) + for tid, val in [ + ("string", "x"), + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool_true", True), + ("bool_false", False), + ("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 [writeConcern.w Range Rejection]: a numeric writeConcern.w outside +# the inclusive supported range (after truncation) produces a parse error. +COLLMOD_WRITE_CONCERN_W_RANGE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"w_range_{wid}", + docs=[], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "writeConcern": {"w": v}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg=f"collMod should reject an out-of-range writeConcern.w of {wid}", + ) + for wid, val in [ + ("fifty_one", 51), + ("negative_one", -1), + ("decimal_above_fifty", Decimal128("123.45")), + ] +] + +# Property [writeConcern.w Type Rejection]: a writeConcern.w value that is not a +# number, string, or object produces a parse error. +COLLMOD_WRITE_CONCERN_W_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"w_type_{tid}", + docs=[], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "writeConcern": {"w": v}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg=f"collMod should reject a {tid} writeConcern.w as non-number/string/object", + ) + for tid, val in [ + ("bool_true", True), + ("bool_false", False), + ("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 [writeConcern.w Quorum Rejection On Standalone]: a quorum write +# concern (a number above 1, an unrecognized string tag, the empty string, or +# null) is rejected up front on a standalone with a BadValue error, since a +# standalone can never satisfy a quorum concern. +COLLMOD_WRITE_CONCERN_W_QUORUM_STANDALONE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"w_quorum_reject_{wid}", + docs=[], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "writeConcern": {"w": v}, + }, + error_code=BAD_VALUE_ERROR, + msg=f"collMod should reject a {wid} quorum writeConcern.w up front on a standalone", + marks=(pytest.mark.requires(quorum_write_concern=False),), + ) + for wid, val in [ + ("int_fifty", 50), + ("decimal_above_one", Decimal128("5")), + ("string_arbitrary", "foo"), + ("string_empty", ""), + ("null", None), + ] +] + +# Property [writeConcern Unknown Field Rejection]: an unrecognized writeConcern +# sub-field, and a write concern option placed at the top level instead of +# nested under writeConcern, each produce an unknown-field error. +COLLMOD_WRITE_CONCERN_UNKNOWN_FIELD_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "unknown_subfield", + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "writeConcern": {"bogus": 1}, + }, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="collMod should reject an unknown writeConcern sub-field", + ), + CommandTestCase( + "bare_top_level_w", + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "w": 1, + }, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="collMod should reject a bare top-level w not nested under writeConcern", + ), +] + +COLLMOD_WRITE_CONCERN_ALL_TESTS: list[CommandTestCase] = ( + COLLMOD_WRITE_CONCERN_SUCCESS_TESTS + + COLLMOD_WRITE_CONCERN_W_SUCCESS_TESTS + + COLLMOD_WRITE_CONCERN_W_QUORUM_SUCCESS_TESTS + + COLLMOD_WRITE_CONCERN_J_SUCCESS_TESTS + + COLLMOD_WRITE_CONCERN_WTIMEOUT_SUCCESS_TESTS + + COLLMOD_WRITE_CONCERN_J_TYPE_ERROR_TESTS + + COLLMOD_WRITE_CONCERN_TYPE_ERROR_TESTS + + COLLMOD_WRITE_CONCERN_W_RANGE_ERROR_TESTS + + COLLMOD_WRITE_CONCERN_W_TYPE_ERROR_TESTS + + COLLMOD_WRITE_CONCERN_W_QUORUM_STANDALONE_ERROR_TESTS + + COLLMOD_WRITE_CONCERN_UNKNOWN_FIELD_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_WRITE_CONCERN_ALL_TESTS)) +def test_collMod_write_concern(database_client, collection, test): + """Test collMod writeConcern option behavior.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/test_changeStream_early_start.py b/documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/test_changeStream_early_start.py index f0f53ebc0..901dcd8c7 100644 --- a/documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/test_changeStream_early_start.py +++ b/documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/test_changeStream_early_start.py @@ -129,8 +129,15 @@ def test_changeStream_early_start_cluster_scope( """Test $changeStream accepts an early startAtOperationTime on a cluster-wide stream.""" start = test_case.compute_start(_oldest_oplog_ts(collection)) spec = {"startAtOperationTime": start, "allChangesForCluster": True} + # batchSize 0 opens the stream without materializing historical events, whose + # resume tokens can exceed the BSON document size limit on a cluster-wide stream. result = execute_admin_command( collection, - change_stream_command(collection, pipeline=[{"$changeStream": spec}], aggregate=1), + change_stream_command( + collection, + pipeline=[{"$changeStream": spec}], + aggregate=1, + cursor={"batchSize": 0}, + ), ) assertResult(result, expected=test_case.expected, msg=test_case.msg, raw_res=True) diff --git a/documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/test_changeStream_timestamp_boundary.py b/documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/test_changeStream_timestamp_boundary.py index e136e6009..8d41357e7 100644 --- a/documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/test_changeStream_timestamp_boundary.py +++ b/documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/test_changeStream_timestamp_boundary.py @@ -110,8 +110,15 @@ def test_changeStream_timestamp_boundary_cluster_scope( ) start = test_case.compute_start(base["operationTime"]) spec = {"startAtOperationTime": start, "allChangesForCluster": True} + # batchSize 0 opens the stream without materializing historical events, whose + # resume tokens can exceed the BSON document size limit on a cluster-wide stream. result = execute_admin_command( collection, - change_stream_command(collection, pipeline=[{"$changeStream": spec}], aggregate=1), + change_stream_command( + collection, + pipeline=[{"$changeStream": spec}], + aggregate=1, + cursor={"batchSize": 0}, + ), ) assertResult(result, expected=test_case.expected, msg=test_case.msg, raw_res=True) diff --git a/documentdb_tests/compatibility/tests/core/operator/update/array/push/__init__.py b/documentdb_tests/compatibility/tests/core/operator/update/array/push/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/update/array/push/test_push_bson_type_validation.py b/documentdb_tests/compatibility/tests/core/operator/update/array/push/test_push_bson_type_validation.py new file mode 100644 index 000000000..bb0b290c0 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/update/array/push/test_push_bson_type_validation.py @@ -0,0 +1,95 @@ +"""Tests for $push BSON type validation. + +Verifies that $push rejects non-array target field types with error code 2, +and can push any BSON type as a value into 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 + +PUSH_PARAMS = [ + BsonTypeTestCase( + id="target_field", + msg="$push should reject non-array target field types", + valid_types=[BsonType.ARRAY], + default_error_code=BAD_VALUE_ERROR, + expected=[{"_id": 1, "arr": [1, 2, 99]}], + valid_inputs={BsonType.ARRAY: [1, 2]}, + ), + BsonTypeTestCase( + id="value_element", + msg="$push should accept any BSON type as value to push", + valid_types=list(BsonType), + default_error_code=BAD_VALUE_ERROR, + 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} + return {"_id": 1, "arr": []} + + +def _build_update(spec, sample_value) -> dict: + """Build the update command based on which aspect is being tested.""" + if spec.id == "target_field": + return {"$push": {"arr": 99}} + return {"$push": {"arr": sample_value}} + + +@pytest.mark.parametrize( + "bson_type,sample_value,spec", generate_bson_rejection_test_cases(PUSH_PARAMS) +) +def test_push_bson_type_rejected(collection, bson_type, sample_value, spec): + """Test $push rejects non-array target field 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"$push should reject {bson_type.value} for {spec.id}", + ) + + +@pytest.mark.parametrize( + "bson_type,sample_value,spec", generate_bson_acceptance_test_cases(PUSH_PARAMS) +) +def test_push_bson_type_accepted(collection, bson_type, sample_value, spec): + """Test $push accepts valid BSON types for target field and value element.""" + 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}}) + if spec.id == "target_field": + expected = spec.expected + else: + expected = [{"_id": 1, "arr": [sample_value]}] + assertSuccess(result, expected, msg=f"$push should accept {bson_type.value} for {spec.id}") diff --git a/documentdb_tests/compatibility/tests/core/operator/update/array/push/test_push_commands.py b/documentdb_tests/compatibility/tests/core/operator/update/array/push/test_push_commands.py new file mode 100644 index 000000000..ba4babb9f --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/update/array/push/test_push_commands.py @@ -0,0 +1,139 @@ +"""Tests for $push update command behavior. + +Covers: updateOne, updateMany, upsert, bulkWrite. +""" + +from documentdb_tests.framework.assertions import assertSuccess, assertSuccessPartial +from documentdb_tests.framework.executor import execute_command + + +def test_push_updateOne_response(collection): + """Test $push with updateOne reports correct response.""" + 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": {"$push": {"a": 4}}}], + }, + ) + assertSuccessPartial( + result, {"n": 1, "nModified": 1, "ok": 1.0}, msg="updateOne should report nModified=1" + ) + + +def test_push_updateOne_result(collection): + """Test $push with updateOne produces correct document.""" + collection.insert_one({"_id": 1, "arr": [1, 2, 3]}) + execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$push": {"arr": 4}}}], + }, + ) + result = execute_command(collection, {"find": collection.name, "filter": {"_id": 1}}) + assertSuccess(result, [{"_id": 1, "arr": [1, 2, 3, 4]}], msg="updateOne should append value") + + +def test_push_updateMany(collection): + """Test $push with updateMany updates all matched docs.""" + collection.insert_many( + [{"_id": 1, "arr": [1]}, {"_id": 2, "arr": [10]}, {"_id": 3, "arr": [100]}] + ) + execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {}, "u": {"$push": {"arr": 99}}, "multi": True}], + }, + ) + result = execute_command( + collection, {"find": collection.name, "filter": {}, "sort": {"_id": 1}} + ) + assertSuccess( + result, + [ + {"_id": 1, "arr": [1, 99]}, + {"_id": 2, "arr": [10, 99]}, + {"_id": 3, "arr": [100, 99]}, + ], + msg="updateMany should push to each matched document", + ) + + +def test_push_upsert_creates_doc(collection): + """Test $push with upsert:true creates document when not found.""" + execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 99}, "u": {"$push": {"arr": 5}}, "upsert": True}], + }, + ) + result = execute_command(collection, {"find": collection.name, "filter": {"_id": 99}}) + assertSuccess( + result, + [{"_id": 99, "arr": [5]}], + msg="Upsert should create doc with array containing the value", + ) + + +def test_push_upsert_with_each(collection): + """Test $push with $each and upsert:true creates document with all values.""" + execute_command( + collection, + { + "update": collection.name, + "updates": [ + {"q": {"_id": 99}, "u": {"$push": {"arr": {"$each": [1, 2, 3]}}}, "upsert": True} + ], + }, + ) + result = execute_command(collection, {"find": collection.name, "filter": {"_id": 99}}) + assertSuccess( + result, + [{"_id": 99, "arr": [1, 2, 3]}], + msg="Upsert with $each should create doc with all values", + ) + + +def test_push_bulk_write_response(collection): + """Test $push in bulkWrite reports correct response.""" + collection.insert_many([{"_id": 1, "arr": [1]}, {"_id": 2, "arr": [10]}]) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [ + {"q": {"_id": 1}, "u": {"$push": {"arr": 2}}}, + {"q": {"_id": 2}, "u": {"$push": {"arr": 20}}}, + ], + }, + ) + assertSuccessPartial( + result, {"n": 2, "nModified": 2, "ok": 1.0}, msg="Bulk update should report nModified=2" + ) + + +def test_push_bulk_write_result(collection): + """Test $push in bulkWrite produces correct documents.""" + collection.insert_many([{"_id": 1, "arr": [1]}, {"_id": 2, "arr": [10]}]) + execute_command( + collection, + { + "update": collection.name, + "updates": [ + {"q": {"_id": 1}, "u": {"$push": {"arr": 2}}}, + {"q": {"_id": 2}, "u": {"$push": {"arr": 20}}}, + ], + }, + ) + result = execute_command( + collection, {"find": collection.name, "filter": {}, "sort": {"_id": 1}} + ) + assertSuccess( + result, + [{"_id": 1, "arr": [1, 2]}, {"_id": 2, "arr": [10, 20]}], + msg="Bulk update should push to each doc", + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/update/array/push/test_push_core.py b/documentdb_tests/compatibility/tests/core/operator/update/array/push/test_push_core.py new file mode 100644 index 000000000..667fd1a40 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/update/array/push/test_push_core.py @@ -0,0 +1,204 @@ +"""Tests for $push core behavior. + +Covers: basic append, missing field creation, array as value, empty operand, +dot notation/nested fields, multiple fields, large arrays, $sort without $each literal. +""" + +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 + +CORE_BEHAVIOR_TESTS: list[UpdateTestCase] = [ + UpdateTestCase( + "append_to_existing_array", + setup_docs=[{"_id": 1, "arr": [1, 2, 3]}], + query={"_id": 1}, + update={"$push": {"arr": 4}}, + expected={"_id": 1, "arr": [1, 2, 3, 4]}, + msg="$push should append value to end of existing array", + ), + UpdateTestCase( + "append_to_empty_array", + setup_docs=[{"_id": 1, "arr": []}], + query={"_id": 1}, + update={"$push": {"arr": "first"}}, + expected={"_id": 1, "arr": ["first"]}, + msg="$push on empty array should add as first element", + ), + UpdateTestCase( + "missing_field_creates_array", + setup_docs=[{"_id": 1, "x": 1}], + query={"_id": 1}, + update={"$push": {"arr": 42}}, + expected={"_id": 1, "x": 1, "arr": [42]}, + msg="$push on missing field should create array and preserve existing fields", + ), + UpdateTestCase( + "array_value_appended_as_single_element", + setup_docs=[{"_id": 1, "arr": [1, 2]}], + query={"_id": 1}, + update={"$push": {"arr": [3, 4]}}, + expected={"_id": 1, "arr": [1, 2, [3, 4]]}, + msg="$push with array value should append entire array as single element", + ), + UpdateTestCase( + "nested_array_value", + setup_docs=[{"_id": 1, "arr": []}], + query={"_id": 1}, + update={"$push": {"arr": [[1, 2], [3, 4]]}}, + expected={"_id": 1, "arr": [[[1, 2], [3, 4]]]}, + msg="$push with nested array appends the whole nested array as one element", + ), + UpdateTestCase( + "object_value", + setup_docs=[{"_id": 1, "arr": []}], + query={"_id": 1}, + update={"$push": {"arr": {"name": "test", "val": 99}}}, + expected={"_id": 1, "arr": [{"name": "test", "val": 99}]}, + msg="$push with object value should append object to array", + ), + UpdateTestCase( + "duplicate_value_allowed", + setup_docs=[{"_id": 1, "arr": [1, 2, 3]}], + query={"_id": 1}, + update={"$push": {"arr": 2}}, + expected={"_id": 1, "arr": [1, 2, 3, 2]}, + msg="$push should allow duplicate values", + ), + UpdateTestCase( + "empty_operand_noop", + setup_docs=[{"_id": 1, "arr": [1, 2, 3]}], + query={"_id": 1}, + update={"$push": {}}, + expected={"_id": 1, "arr": [1, 2, 3]}, + msg="$push with empty operand {} is a no-op", + ), +] + + +NESTED_FIELD_TESTS: list[UpdateTestCase] = [ + UpdateTestCase( + "dot_notation_nested_array", + setup_docs=[{"_id": 1, "a": {"b": [1, 2]}}], + query={"_id": 1}, + update={"$push": {"a.b": 3}}, + expected={"_id": 1, "a": {"b": [1, 2, 3]}}, + msg="$push on nested array using dot notation", + ), + UpdateTestCase( + "dot_notation_deeply_nested", + setup_docs=[{"_id": 1, "a": {"b": {"c": {"d": [10]}}}}], + query={"_id": 1}, + update={"$push": {"a.b.c.d": 20}}, + expected={"_id": 1, "a": {"b": {"c": {"d": [10, 20]}}}}, + msg="$push on deeply nested array using dot notation", + ), + UpdateTestCase( + "dot_notation_creates_nested_path", + setup_docs=[{"_id": 1}], + query={"_id": 1}, + update={"$push": {"a.b": "val"}}, + expected={"_id": 1, "a": {"b": ["val"]}}, + msg="$push on missing nested field creates structure", + ), + UpdateTestCase( + "array_within_embedded_doc", + setup_docs=[{"_id": 1, "obj": {"items": ["x"]}}], + query={"_id": 1}, + update={"$push": {"obj.items": "y"}}, + expected={"_id": 1, "obj": {"items": ["x", "y"]}}, + msg="$push on array within embedded document", + ), + UpdateTestCase( + "numeric_index_path", + setup_docs=[{"_id": 1, "a": [{"b": [1, 2]}, {"b": [3]}]}], + query={"_id": 1}, + update={"$push": {"a.0.b": 99}}, + expected={"_id": 1, "a": [{"b": [1, 2, 99]}, {"b": [3]}]}, + msg="$push on a.0.b where a is an array uses numeric index", + ), +] + + +MULTIPLE_FIELD_TESTS: list[UpdateTestCase] = [ + UpdateTestCase( + "push_multiple_fields", + setup_docs=[{"_id": 1, "a": [1], "b": [10]}], + query={"_id": 1}, + update={"$push": {"a": 2, "b": 20}}, + expected={"_id": 1, "a": [1, 2], "b": [10, 20]}, + msg="$push on multiple array fields in single operation", + ), + UpdateTestCase( + "push_multiple_fields_independent", + setup_docs=[{"_id": 1, "x": ["a"], "y": [1], "z": [True]}], + query={"_id": 1}, + update={"$push": {"x": "b", "y": 2, "z": False}}, + expected={"_id": 1, "x": ["a", "b"], "y": [1, 2], "z": [True, False]}, + msg="$push on multiple fields should process each independently", + ), + UpdateTestCase( + "push_multiple_fields_with_modifiers", + setup_docs=[{"_id": 1, "a": [3, 1, 2], "b": [10, 20, 30]}], + query={"_id": 1}, + update={ + "$push": { + "a": {"$each": [4], "$sort": 1}, + "b": {"$each": [0], "$position": 0}, + } + }, + expected={"_id": 1, "a": [1, 2, 3, 4], "b": [0, 10, 20, 30]}, + msg="$push multiple fields each with their own modifiers", + ), +] + + +LARGE_ARRAY_TESTS: list[UpdateTestCase] = [ + UpdateTestCase( + "push_to_large_array", + setup_docs=[{"_id": 1, "arr": list(range(1000))}], + query={"_id": 1}, + update={"$push": {"arr": 1000}}, + expected={"_id": 1, "arr": list(range(1001))}, + msg="$push should append to large array correctly", + ), +] + + +MODIFIER_WITHOUT_EACH_TESTS: list[UpdateTestCase] = [ + UpdateTestCase( + "sort_without_each_pushes_literal", + setup_docs=[{"_id": 1, "arr": [3, 1, 2]}], + query={"_id": 1}, + update={"$push": {"arr": {"$sort": 1}}}, + expected={"_id": 1, "arr": [3, 1, 2, {"$sort": 1}]}, + msg="$sort without $each pushes the object as literal value", + ), +] + + +ALL_TESTS = ( + CORE_BEHAVIOR_TESTS + + NESTED_FIELD_TESTS + + MULTIPLE_FIELD_TESTS + + LARGE_ARRAY_TESTS + + MODIFIER_WITHOUT_EACH_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_push_core(collection, test: UpdateTestCase): + """Test $push core behavior produces expected document.""" + 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/push/test_push_errors.py b/documentdb_tests/compatibility/tests/core/operator/update/array/push/test_push_errors.py new file mode 100644 index 000000000..c7dc2667e --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/update/array/push/test_push_errors.py @@ -0,0 +1,23 @@ +"""Tests for $push error handling. + +Covers: path traversal errors specific to $push. +""" + +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import PATH_NOT_VIABLE_ERROR +from documentdb_tests.framework.executor import execute_command + + +def test_push_scalar_intermediate_path_error(collection): + """Test $push through scalar intermediate field should error.""" + collection.insert_one({"_id": 1, "a": 5}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$push": {"a.b": 1}}}], + }, + ) + assertFailureCode( + result, PATH_NOT_VIABLE_ERROR, msg="$push through scalar intermediate field should error" + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/update/test_update_modifier_integration.py b/documentdb_tests/compatibility/tests/core/operator/update/test_update_modifier_integration.py index 979d58c47..ff2401596 100644 --- a/documentdb_tests/compatibility/tests/core/operator/update/test_update_modifier_integration.py +++ b/documentdb_tests/compatibility/tests/core/operator/update/test_update_modifier_integration.py @@ -337,6 +337,14 @@ def test_position_update_modifier_integration(collection, test_case): expected=[{"_id": 1, "arr": [3, 2, 1]}], msg="Sort descending with negative slice should keep last 3 of sorted desc array", ), + UpdateTestCase( + id="sort_slice_removes_new_elements", + setup_docs=[{"_id": 1, "arr": [1, 2, 3]}], + query={"_id": 1}, + update={"$push": {"arr": {"$each": [10, 20], "$sort": 1, "$slice": 3}}}, + expected=[{"_id": 1, "arr": [1, 2, 3]}], + msg="$sort + $slice where slice removes newly added elements", + ), ] diff --git a/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClearFilters/__init__.py b/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClearFilters/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClearFilters/test_planCacheClearFilters_behavior.py b/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClearFilters/test_planCacheClearFilters_behavior.py new file mode 100644 index 000000000..3138249d5 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClearFilters/test_planCacheClearFilters_behavior.py @@ -0,0 +1,96 @@ +"""Tests for planCacheClearFilters command behavioral verification. + +Verifies that planCacheClearFilters actually removes index filters, not just +that it returns ok: 1.0. Uses planCacheSetFilter to establish filters and +planCacheListFilters to observe filter state before and after clearing. +""" + +from __future__ import annotations + +from pymongo.collection import Collection + +from documentdb_tests.framework.assertions import assertNotError, assertSuccess +from documentdb_tests.framework.executor import execute_command + + +def _set_filters(collection: Collection) -> None: + """Set two distinct index filters on the collection. + + Raises AssertionError if the filters are not visible via + planCacheListFilters after being set. + """ + collection.insert_many([{"_id": i, "a": i, "b": i % 5} for i in range(10)]) + collection.create_index("a") + collection.create_index("b") + + db = collection.database + db.command( + { + "planCacheSetFilter": collection.name, + "query": {"a": 1}, + "indexes": [{"a": 1}], + } + ) + db.command( + { + "planCacheSetFilter": collection.name, + "query": {"b": 1}, + "indexes": [{"b": 1}], + } + ) + + list_result = execute_command(collection, {"planCacheListFilters": collection.name}) + assertNotError(list_result, msg="planCacheListFilters precondition check") + filters = list_result.get("filters", []) + if len(filters) < 2: + raise AssertionError( + f"Precondition failed: expected at least 2 index filters " + f"after setup, got {len(filters)}" + ) + + +# Property [Clear All Filters]: planCacheClearFilters without query shape +# parameters removes all index filters for the collection. +def test_planCacheClearFilters_clears_all_filters(collection: Collection): + """Test planCacheClearFilters removes all index filters.""" + _set_filters(collection) + + execute_command(collection, {"planCacheClearFilters": collection.name}) + + result = execute_command(collection, {"planCacheListFilters": collection.name}) + assertSuccess( + result, + {"filters": [], "ok": 1.0}, + msg="All index filters should be removed after planCacheClearFilters", + raw_res=True, + ) + + +# Property [Targeted Clear]: planCacheClearFilters with a query shape removes +# only the matching filter and leaves other filters intact. +def test_planCacheClearFilters_targeted_preserves_other_filters(collection: Collection): + """Test planCacheClearFilters with query shape leaves non-targeted filters.""" + _set_filters(collection) + + execute_command( + collection, + {"planCacheClearFilters": collection.name, "query": {"a": 1}}, + ) + + result = execute_command(collection, {"planCacheListFilters": collection.name}) + assertSuccess( + result, + { + "filters": [ + { + "query": {"b": 1}, + "sort": {}, + "projection": {}, + "indexes": [{"b": 1}], + } + ], + "ok": 1.0, + }, + msg="Only the non-targeted filter should remain after targeted planCacheClearFilters", + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClearFilters/test_planCacheClearFilters_core.py b/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClearFilters/test_planCacheClearFilters_core.py new file mode 100644 index 000000000..eb6bdae36 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClearFilters/test_planCacheClearFilters_core.py @@ -0,0 +1,291 @@ +"""Tests for planCacheClearFilters command core behavior.""" + +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_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.target_collection import ( + CappedCollection, + ClusteredCollection, +) + +# Property [Basic Success]: planCacheClearFilters succeeds on existing, empty, +# and non-existent collections, returning ok: 1.0. +CLEAR_FILTERS_BASIC_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "basic_with_documents", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"planCacheClearFilters": ctx.collection}, + expected={"ok": 1.0}, + msg="planCacheClearFilters should succeed on collection with documents", + ), + CommandTestCase( + "basic_empty_collection", + docs=[], + command=lambda ctx: {"planCacheClearFilters": ctx.collection}, + expected={"ok": 1.0}, + msg="planCacheClearFilters should succeed on empty collection", + ), + CommandTestCase( + "basic_nonexistent_collection", + docs=None, + command=lambda ctx: {"planCacheClearFilters": ctx.collection}, + expected={"ok": 1.0}, + msg="planCacheClearFilters should succeed on non-existent collection", + ), +] + +# Property [Query Shape]: planCacheClearFilters accepts optional query, sort, +# and projection parameters to target a specific index filter. +CLEAR_FILTERS_QUERY_SHAPE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "query_only", + command=lambda ctx: { + "planCacheClearFilters": ctx.collection, + "query": {"a": 1}, + }, + expected={"ok": 1.0}, + msg="planCacheClearFilters should accept query only", + ), + CommandTestCase( + "query_and_sort", + command=lambda ctx: { + "planCacheClearFilters": ctx.collection, + "query": {"a": 1}, + "sort": {"a": 1}, + }, + expected={"ok": 1.0}, + msg="planCacheClearFilters should accept query and sort", + ), + CommandTestCase( + "query_and_projection", + command=lambda ctx: { + "planCacheClearFilters": ctx.collection, + "query": {"a": 1}, + "projection": {"a": 1}, + }, + expected={"ok": 1.0}, + msg="planCacheClearFilters should accept query and projection", + ), + CommandTestCase( + "query_sort_projection", + command=lambda ctx: { + "planCacheClearFilters": ctx.collection, + "query": {"a": 1}, + "sort": {"a": 1}, + "projection": {"a": 1}, + }, + expected={"ok": 1.0}, + msg="planCacheClearFilters should accept query, sort, and projection", + ), +] + +# Property [Parameter Combinations]: planCacheClearFilters supports various +# valid combinations of all parameters. +CLEAR_FILTERS_PARAM_COMBO_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "query_sort_projection_comment", + command=lambda ctx: { + "planCacheClearFilters": ctx.collection, + "query": {"a": 1}, + "sort": {"a": 1}, + "projection": {"a": 1}, + "comment": "test", + }, + expected={"ok": 1.0}, + msg="planCacheClearFilters should accept query, sort, projection, and comment", + ), + CommandTestCase( + "query_sort_projection_collation", + command=lambda ctx: { + "planCacheClearFilters": ctx.collection, + "query": {"a": 1}, + "sort": {"a": 1}, + "projection": {"a": 1}, + "collation": {"locale": "en"}, + }, + expected={"ok": 1.0}, + msg="planCacheClearFilters should accept query, sort, projection, and collation", + ), + CommandTestCase( + "all_parameters", + command=lambda ctx: { + "planCacheClearFilters": ctx.collection, + "query": {"a": 1}, + "sort": {"a": 1}, + "projection": {"a": 1}, + "collation": {"locale": "en"}, + "comment": "test", + }, + expected={"ok": 1.0}, + msg="planCacheClearFilters should accept all parameters together", + ), + CommandTestCase( + "comment_only", + command=lambda ctx: {"planCacheClearFilters": ctx.collection, "comment": "test"}, + expected={"ok": 1.0}, + msg="planCacheClearFilters should accept comment without query shape", + ), + CommandTestCase( + "query_and_comment", + command=lambda ctx: { + "planCacheClearFilters": ctx.collection, + "query": {"a": 1}, + "comment": "test", + }, + expected={"ok": 1.0}, + msg="planCacheClearFilters should accept query and comment", + ), +] + +# Property [Null Optional Parameters]: when optional parameters are set to +# null, the command treats them as omitted and succeeds. +CLEAR_FILTERS_NULL_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "null_query", + command=lambda ctx: {"planCacheClearFilters": ctx.collection, "query": None}, + expected={"ok": 1.0}, + msg="planCacheClearFilters should treat null query as omitted", + ), + CommandTestCase( + "null_sort", + command=lambda ctx: {"planCacheClearFilters": ctx.collection, "sort": None}, + expected={"ok": 1.0}, + msg="planCacheClearFilters should treat null sort as omitted", + ), + CommandTestCase( + "null_projection", + command=lambda ctx: {"planCacheClearFilters": ctx.collection, "projection": None}, + expected={"ok": 1.0}, + msg="planCacheClearFilters should treat null projection as omitted", + ), + CommandTestCase( + "null_comment", + command=lambda ctx: {"planCacheClearFilters": ctx.collection, "comment": None}, + expected={"ok": 1.0}, + msg="planCacheClearFilters should treat null comment as omitted", + ), + CommandTestCase( + "null_all_optional", + command=lambda ctx: { + "planCacheClearFilters": ctx.collection, + "query": None, + "sort": None, + "projection": None, + "comment": None, + }, + expected={"ok": 1.0}, + msg="planCacheClearFilters should treat all null optional params as omitted", + ), +] + +# Property [Parameter Independence]: sort, projection, and collation succeed +# without query. +CLEAR_FILTERS_INDEPENDENCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "sort_without_query", + command=lambda ctx: { + "planCacheClearFilters": ctx.collection, + "sort": {"a": 1}, + }, + expected={"ok": 1.0}, + msg="planCacheClearFilters should accept sort without query", + ), + CommandTestCase( + "projection_without_query", + command=lambda ctx: { + "planCacheClearFilters": ctx.collection, + "projection": {"a": 1}, + }, + expected={"ok": 1.0}, + msg="planCacheClearFilters should accept projection without query", + ), + CommandTestCase( + "sort_and_projection_without_query", + command=lambda ctx: { + "planCacheClearFilters": ctx.collection, + "sort": {"a": 1}, + "projection": {"a": 1}, + }, + expected={"ok": 1.0}, + msg="planCacheClearFilters should accept sort and projection without query", + ), + CommandTestCase( + "collation_without_query", + command=lambda ctx: { + "planCacheClearFilters": ctx.collection, + "collation": {"locale": "en"}, + }, + expected={"ok": 1.0}, + msg="planCacheClearFilters should accept collation without query", + ), +] + +# Property [Capped Collection]: planCacheClearFilters succeeds on capped +# collections. +CLEAR_FILTERS_CAPPED_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "capped_collection", + target_collection=CappedCollection(size=4096), + docs=[{"_id": 1}], + command=lambda ctx: {"planCacheClearFilters": ctx.collection}, + expected={"ok": 1.0}, + msg="planCacheClearFilters should succeed on a capped collection", + ), +] + +# Property [Clustered Collection]: planCacheClearFilters succeeds on clustered +# collections. +CLEAR_FILTERS_CLUSTERED_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "clustered_collection", + target_collection=ClusteredCollection(), + docs=[{"_id": 1}], + command=lambda ctx: {"planCacheClearFilters": ctx.collection}, + expected={"ok": 1.0}, + msg="planCacheClearFilters should succeed on a clustered collection", + ), +] + +CLEAR_FILTERS_CORE_TESTS: list[CommandTestCase] = ( + CLEAR_FILTERS_BASIC_TESTS + + CLEAR_FILTERS_QUERY_SHAPE_TESTS + + CLEAR_FILTERS_PARAM_COMBO_TESTS + + CLEAR_FILTERS_NULL_TESTS + + CLEAR_FILTERS_INDEPENDENCE_TESTS + + CLEAR_FILTERS_CAPPED_TESTS + + CLEAR_FILTERS_CLUSTERED_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(CLEAR_FILTERS_CORE_TESTS)) +def test_planCacheClearFilters_core(database_client, collection, test): + """Test planCacheClearFilters command core behavior.""" + 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 [Repeated Clear All]: Running planCacheClearFilters multiple times +# in succession without setting any filters always succeeds. +def test_planCacheClearFilters_repeated_clear_all(collection): + """Test planCacheClearFilters succeeds when called multiple times in succession.""" + for i in range(3): + result = execute_command(collection, {"planCacheClearFilters": collection.name}) + assertSuccessPartial( + result, + {"ok": 1.0}, + msg=f"planCacheClearFilters call {i + 1} of 3 should succeed", + ) diff --git a/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClearFilters/test_planCacheClearFilters_edge_cases.py b/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClearFilters/test_planCacheClearFilters_edge_cases.py new file mode 100644 index 000000000..425d5a783 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClearFilters/test_planCacheClearFilters_edge_cases.py @@ -0,0 +1,209 @@ +"""Tests for planCacheClearFilters command edge cases and permissiveness.""" + +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.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.target_collection import NamedCollection + +# Property [Unknown Fields Accepted]: planCacheClearFilters silently accepts +# unrecognized fields without error. +CLEAR_FILTERS_UNKNOWN_FIELD_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "unknown_field_foo", + command=lambda ctx: {"planCacheClearFilters": ctx.collection, "foo": "bar"}, + expected={"ok": 1.0}, + msg="planCacheClearFilters should silently accept unknown field", + ), + CommandTestCase( + "case_variation_Query", + command=lambda ctx: { + "planCacheClearFilters": ctx.collection, + "Query": {"a": 1}, + }, + expected={"ok": 1.0}, + msg="planCacheClearFilters should treat capitalized Query as unknown field", + ), + CommandTestCase( + "case_variation_Sort", + command=lambda ctx: { + "planCacheClearFilters": ctx.collection, + "Sort": {"a": 1}, + }, + expected={"ok": 1.0}, + msg="planCacheClearFilters should treat capitalized Sort as unknown field", + ), + CommandTestCase( + "case_variation_Projection", + command=lambda ctx: { + "planCacheClearFilters": ctx.collection, + "Projection": {"a": 1}, + }, + expected={"ok": 1.0}, + msg="planCacheClearFilters should treat capitalized Projection as unknown field", + ), +] + +# Property [Collation Valid]: planCacheClearFilters accepts valid collation +# with query. +CLEAR_FILTERS_COLLATION_VALID_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "collation_locale_en", + command=lambda ctx: { + "planCacheClearFilters": ctx.collection, + "query": {"a": 1}, + "collation": {"locale": "en"}, + }, + expected={"ok": 1.0}, + msg="planCacheClearFilters should accept collation with locale en", + ), + CommandTestCase( + "collation_locale_fr_strength_2", + command=lambda ctx: { + "planCacheClearFilters": ctx.collection, + "query": {"a": 1}, + "collation": {"locale": "fr", "strength": 2}, + }, + expected={"ok": 1.0}, + msg="planCacheClearFilters should accept collation with locale and strength", + ), +] + +# Property [Collation Permissiveness]: collation accepts values that would +# normally be invalid because MongoDB silently accepts them in this command. +CLEAR_FILTERS_COLLATION_PERMISSIVE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "collation_empty_document", + command=lambda ctx: { + "planCacheClearFilters": ctx.collection, + "collation": {}, + }, + expected={"ok": 1.0}, + msg="planCacheClearFilters should accept empty collation document", + ), + CommandTestCase( + "collation_string", + command=lambda ctx: { + "planCacheClearFilters": ctx.collection, + "collation": "en", + }, + expected={"ok": 1.0}, + msg="planCacheClearFilters should accept string collation", + ), + CommandTestCase( + "collation_int", + command=lambda ctx: { + "planCacheClearFilters": ctx.collection, + "collation": 123, + }, + expected={"ok": 1.0}, + msg="planCacheClearFilters should accept int collation", + ), + CommandTestCase( + "collation_bool", + command=lambda ctx: { + "planCacheClearFilters": ctx.collection, + "collation": True, + }, + expected={"ok": 1.0}, + msg="planCacheClearFilters should accept bool collation", + ), + CommandTestCase( + "collation_array", + command=lambda ctx: { + "planCacheClearFilters": ctx.collection, + "collation": [1], + }, + expected={"ok": 1.0}, + msg="planCacheClearFilters should accept array collation", + ), + CommandTestCase( + "collation_null", + command=lambda ctx: { + "planCacheClearFilters": ctx.collection, + "collation": None, + }, + expected={"ok": 1.0}, + msg="planCacheClearFilters should accept null collation", + ), +] + +# Property [Collection Name Edge Cases]: planCacheClearFilters succeeds with +# special characters, unicode, and long collection names. +CLEAR_FILTERS_NAME_EDGE_CASE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "name_long", + target_collection=NamedCollection(suffix="_" + "a" * 150), + docs=[{"_id": 1}], + command=lambda ctx: {"planCacheClearFilters": ctx.collection}, + expected={"ok": 1.0}, + msg="planCacheClearFilters should succeed with a long collection name", + ), + CommandTestCase( + "name_hyphen", + target_collection=NamedCollection(suffix="_my-coll"), + docs=[{"_id": 1}], + command=lambda ctx: {"planCacheClearFilters": ctx.collection}, + expected={"ok": 1.0}, + msg="planCacheClearFilters should succeed with hyphen in name", + ), + CommandTestCase( + "name_unicode", + target_collection=NamedCollection(suffix="_\u00e9"), + docs=[{"_id": 1}], + command=lambda ctx: {"planCacheClearFilters": ctx.collection}, + expected={"ok": 1.0}, + msg="planCacheClearFilters should succeed with unicode name", + ), +] + +# Property [Comment Edge Cases]: planCacheClearFilters succeeds with edge-case +# comment values. +CLEAR_FILTERS_COMMENT_EDGE_CASE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "comment_long", + command=lambda ctx: { + "planCacheClearFilters": ctx.collection, + "comment": "x" * 10_000, + }, + expected={"ok": 1.0}, + msg="planCacheClearFilters should succeed with very long comment", + ), + CommandTestCase( + "comment_empty_string", + command=lambda ctx: { + "planCacheClearFilters": ctx.collection, + "comment": "", + }, + expected={"ok": 1.0}, + msg="planCacheClearFilters should succeed with empty string comment", + ), +] + +CLEAR_FILTERS_EDGE_CASE_TESTS: list[CommandTestCase] = ( + CLEAR_FILTERS_UNKNOWN_FIELD_TESTS + + CLEAR_FILTERS_COLLATION_VALID_TESTS + + CLEAR_FILTERS_COLLATION_PERMISSIVE_TESTS + + CLEAR_FILTERS_NAME_EDGE_CASE_TESTS + + CLEAR_FILTERS_COMMENT_EDGE_CASE_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(CLEAR_FILTERS_EDGE_CASE_TESTS)) +def test_planCacheClearFilters_edge_cases(database_client, collection, test): + """Test planCacheClearFilters command edge cases and permissiveness.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClearFilters/test_planCacheClearFilters_errors.py b/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClearFilters/test_planCacheClearFilters_errors.py new file mode 100644 index 000000000..3a7feb02c --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClearFilters/test_planCacheClearFilters_errors.py @@ -0,0 +1,192 @@ +"""Tests for planCacheClearFilters command error cases.""" + +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 ( + COMMAND_NOT_SUPPORTED_ON_VIEW_ERROR, + INVALID_NAMESPACE_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.target_collection import ( + TimeseriesCollection, + ViewCollection, +) + +# Property [View Rejection]: planCacheClearFilters is not supported on views. +CLEAR_FILTERS_VIEW_REJECTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "view_rejection", + target_collection=ViewCollection(), + command=lambda ctx: {"planCacheClearFilters": ctx.collection}, + error_code=COMMAND_NOT_SUPPORTED_ON_VIEW_ERROR, + msg="planCacheClearFilters should be rejected on a view", + ), +] + +# Property [Timeseries Rejection]: planCacheClearFilters is not supported on +# timeseries collections. +CLEAR_FILTERS_TIMESERIES_REJECTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "timeseries_rejection", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx: {"planCacheClearFilters": ctx.collection}, + error_code=COMMAND_NOT_SUPPORTED_ON_VIEW_ERROR, + msg="planCacheClearFilters should be rejected on a timeseries collection", + ), +] + +# Property [Field Type Rejection]: all non-string BSON types for the +# planCacheClearFilters field produce an invalid namespace error. +CLEAR_FILTERS_NAME_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "name_type_int32", + command={"planCacheClearFilters": 123}, + error_code=INVALID_NAMESPACE_ERROR, + msg="int32 collection name should be rejected as invalid type", + ), + CommandTestCase( + "name_type_int64", + command={"planCacheClearFilters": Int64(1)}, + error_code=INVALID_NAMESPACE_ERROR, + msg="Int64 collection name should be rejected as invalid type", + ), + CommandTestCase( + "name_type_double", + command={"planCacheClearFilters": 1.5}, + error_code=INVALID_NAMESPACE_ERROR, + msg="double collection name should be rejected as invalid type", + ), + CommandTestCase( + "name_type_decimal128", + command={"planCacheClearFilters": Decimal128("1")}, + error_code=INVALID_NAMESPACE_ERROR, + msg="Decimal128 collection name should be rejected as invalid type", + ), + CommandTestCase( + "name_type_bool_true", + command={"planCacheClearFilters": True}, + error_code=INVALID_NAMESPACE_ERROR, + msg="bool true collection name should be rejected as invalid type", + ), + CommandTestCase( + "name_type_bool_false", + command={"planCacheClearFilters": False}, + error_code=INVALID_NAMESPACE_ERROR, + msg="bool false collection name should be rejected as invalid type", + ), + CommandTestCase( + "name_type_null", + command={"planCacheClearFilters": None}, + error_code=INVALID_NAMESPACE_ERROR, + msg="null collection name should be rejected as invalid type", + ), + CommandTestCase( + "name_type_array", + command={"planCacheClearFilters": []}, + error_code=INVALID_NAMESPACE_ERROR, + msg="array collection name should be rejected as invalid type", + ), + CommandTestCase( + "name_type_object", + command={"planCacheClearFilters": {}}, + error_code=INVALID_NAMESPACE_ERROR, + msg="object collection name should be rejected as invalid type", + ), + CommandTestCase( + "name_type_objectid", + command={"planCacheClearFilters": ObjectId()}, + error_code=INVALID_NAMESPACE_ERROR, + msg="ObjectId collection name should be rejected as invalid type", + ), + CommandTestCase( + "name_type_binary", + command={"planCacheClearFilters": Binary(b"\x00")}, + error_code=INVALID_NAMESPACE_ERROR, + msg="Binary collection name should be rejected as invalid type", + ), + CommandTestCase( + "name_type_datetime", + command={"planCacheClearFilters": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + error_code=INVALID_NAMESPACE_ERROR, + msg="datetime collection name should be rejected as invalid type", + ), + CommandTestCase( + "name_type_timestamp", + command={"planCacheClearFilters": Timestamp(0, 0)}, + error_code=INVALID_NAMESPACE_ERROR, + msg="Timestamp collection name should be rejected as invalid type", + ), + CommandTestCase( + "name_type_regex", + command={"planCacheClearFilters": Regex(".*")}, + error_code=INVALID_NAMESPACE_ERROR, + msg="Regex collection name should be rejected as invalid type", + ), + CommandTestCase( + "name_type_code", + command={"planCacheClearFilters": Code("function(){}")}, + error_code=INVALID_NAMESPACE_ERROR, + msg="Code collection name should be rejected as invalid type", + ), + CommandTestCase( + "name_type_minkey", + command={"planCacheClearFilters": MinKey()}, + error_code=INVALID_NAMESPACE_ERROR, + msg="MinKey collection name should be rejected as invalid type", + ), + CommandTestCase( + "name_type_maxkey", + command={"planCacheClearFilters": MaxKey()}, + error_code=INVALID_NAMESPACE_ERROR, + msg="MaxKey collection name should be rejected as invalid type", + ), +] + +# Property [Namespace Edge Cases]: empty string and null byte collection +# names produce an invalid namespace error. +CLEAR_FILTERS_NAME_EDGE_CASE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "name_empty_string", + command={"planCacheClearFilters": ""}, + error_code=INVALID_NAMESPACE_ERROR, + msg="Empty string collection name should be rejected", + ), + CommandTestCase( + "name_null_byte_embedded", + command={"planCacheClearFilters": "test\x00coll"}, + error_code=INVALID_NAMESPACE_ERROR, + msg="Null byte embedded in collection name should be rejected", + ), +] + +CLEAR_FILTERS_ERROR_TESTS: list[CommandTestCase] = ( + CLEAR_FILTERS_VIEW_REJECTION_TESTS + + CLEAR_FILTERS_TIMESERIES_REJECTION_TESTS + + CLEAR_FILTERS_NAME_TYPE_ERROR_TESTS + + CLEAR_FILTERS_NAME_EDGE_CASE_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(CLEAR_FILTERS_ERROR_TESTS)) +def test_planCacheClearFilters_errors(database_client, collection, test): + """Test planCacheClearFilters command error cases.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClearFilters/test_planCacheClearFilters_field_types.py b/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClearFilters/test_planCacheClearFilters_field_types.py new file mode 100644 index 000000000..4ece4be83 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClearFilters/test_planCacheClearFilters_field_types.py @@ -0,0 +1,117 @@ +"""Tests for planCacheClearFilters command field type acceptance.""" + +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.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +_BSON_TYPE_VALUES = [ + ("document", {"a": 1}), + ("empty_document", {}), + ("string", "hello"), + ("int32", 123), + ("int64", Int64(1)), + ("double", 1.5), + ("decimal128", Decimal128("1")), + ("bool_true", True), + ("bool_false", False), + ("null", None), + ("array", [1, 2]), + ("binary", Binary(b"\x00")), + ("objectid", ObjectId()), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("regex", Regex(".*")), + ("timestamp", Timestamp(0, 0)), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), +] + +# Property [Query Type Acceptance]: the query field accepts all BSON types +# without type validation. +CLEAR_FILTERS_QUERY_TYPE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"query_{tid}", + command=lambda ctx, v=val: {"planCacheClearFilters": ctx.collection, "query": v}, + expected={"ok": 1.0}, + msg=f"query={tid} should be accepted", + ) + for tid, val in _BSON_TYPE_VALUES +] + +# Property [Sort Type Acceptance]: the sort field accepts all BSON types +# without type validation. +CLEAR_FILTERS_SORT_TYPE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"sort_{tid}", + command=lambda ctx, v=val: { + "planCacheClearFilters": ctx.collection, + "query": {"a": 1}, + "sort": v, + }, + expected={"ok": 1.0}, + msg=f"sort={tid} should be accepted", + ) + for tid, val in _BSON_TYPE_VALUES +] + +# Property [Projection Type Acceptance]: the projection field accepts all +# BSON types without type validation. +CLEAR_FILTERS_PROJECTION_TYPE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"projection_{tid}", + command=lambda ctx, v=val: { + "planCacheClearFilters": ctx.collection, + "query": {"a": 1}, + "projection": v, + }, + expected={"ok": 1.0}, + msg=f"projection={tid} should be accepted", + ) + for tid, val in _BSON_TYPE_VALUES +] + +# Property [Comment Type Acceptance]: the comment field accepts any valid +# BSON type. +CLEAR_FILTERS_COMMENT_TYPE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"comment_{tid}", + command=lambda ctx, v=val: { + "planCacheClearFilters": ctx.collection, + "comment": v, + }, + expected={"ok": 1.0}, + msg=f"comment={tid} should be accepted", + ) + for tid, val in _BSON_TYPE_VALUES +] + +CLEAR_FILTERS_FIELD_TYPE_TESTS: list[CommandTestCase] = ( + CLEAR_FILTERS_QUERY_TYPE_TESTS + + CLEAR_FILTERS_SORT_TYPE_TESTS + + CLEAR_FILTERS_PROJECTION_TYPE_TESTS + + CLEAR_FILTERS_COMMENT_TYPE_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(CLEAR_FILTERS_FIELD_TYPE_TESTS)) +def test_planCacheClearFilters_field_types(database_client, collection, test): + """Test planCacheClearFilters command field type acceptance.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheListFilters/__init__.py b/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheListFilters/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheListFilters/test_planCacheListFilters_core.py b/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheListFilters/test_planCacheListFilters_core.py new file mode 100644 index 000000000..638024741 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheListFilters/test_planCacheListFilters_core.py @@ -0,0 +1,260 @@ +"""Tests for planCacheListFilters command core behavior and field acceptance.""" + +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.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.target_collection import ( + CappedCollection, + ClusteredCollection, + NamedCollection, +) + +# Property [Basic Success]: planCacheListFilters returns ok: 1.0 and empty +# filters array on existing, empty, and non-existent collections. +LIST_FILTERS_BASIC_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "basic_with_documents", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={"filters": [], "ok": 1.0}, + msg="planCacheListFilters should succeed on collection with documents", + ), + CommandTestCase( + "basic_empty_collection", + docs=[], + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={"filters": [], "ok": 1.0}, + msg="planCacheListFilters should succeed on empty collection", + ), + CommandTestCase( + "basic_nonexistent_collection", + docs=None, + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={"filters": [], "ok": 1.0}, + msg="planCacheListFilters should succeed on non-existent collection", + ), +] + +# Property [Capped Collection]: planCacheListFilters succeeds on capped +# collections. +LIST_FILTERS_CAPPED_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "capped_collection", + target_collection=CappedCollection(size=4096), + docs=[{"_id": 1}], + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={"filters": [], "ok": 1.0}, + msg="planCacheListFilters should succeed on a capped collection", + ), +] + +# Property [Clustered Collection]: planCacheListFilters succeeds on clustered +# collections. +LIST_FILTERS_CLUSTERED_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "clustered_collection", + target_collection=ClusteredCollection(), + docs=[{"_id": 1}], + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={"filters": [], "ok": 1.0}, + msg="planCacheListFilters should succeed on a clustered collection", + ), +] + +# Property [Unknown Fields Accepted]: planCacheListFilters silently accepts +# unrecognized fields without error. +LIST_FILTERS_UNKNOWN_FIELD_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "accepts_unknown_field", + command=lambda ctx: {"planCacheListFilters": ctx.collection, "foo": "bar"}, + expected={"filters": [], "ok": 1.0}, + msg="planCacheListFilters should silently accept unknown field", + ), + CommandTestCase( + "accepts_case_variant_Comment", + command=lambda ctx: { + "planCacheListFilters": ctx.collection, + "Comment": "test", + }, + expected={"filters": [], "ok": 1.0}, + msg="planCacheListFilters should treat capitalized Comment as unknown field", + ), + CommandTestCase( + "accepts_case_variant_Query", + command=lambda ctx: { + "planCacheListFilters": ctx.collection, + "Query": {"a": 1}, + }, + expected={"filters": [], "ok": 1.0}, + msg="planCacheListFilters should treat capitalized Query as unknown field", + ), + CommandTestCase( + "accepts_case_variant_Sort", + command=lambda ctx: { + "planCacheListFilters": ctx.collection, + "Sort": {"a": 1}, + }, + expected={"filters": [], "ok": 1.0}, + msg="planCacheListFilters should treat capitalized Sort as unknown field", + ), +] + +# Property [Collection Name Edge Cases]: planCacheListFilters succeeds with +# special characters, unicode, and long collection names. +LIST_FILTERS_NAME_EDGE_CASE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "name_accepts_long_suffix", + target_collection=NamedCollection(suffix="_" + "a" * 150), + docs=[{"_id": 1}], + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={"filters": [], "ok": 1.0}, + msg="planCacheListFilters should succeed with a long collection name", + ), + CommandTestCase( + "name_accepts_hyphen", + target_collection=NamedCollection(suffix="_my-coll"), + docs=[{"_id": 1}], + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={"filters": [], "ok": 1.0}, + msg="planCacheListFilters should succeed with hyphen in name", + ), + CommandTestCase( + "name_accepts_unicode", + target_collection=NamedCollection(suffix="_\u00e9"), + docs=[{"_id": 1}], + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={"filters": [], "ok": 1.0}, + msg="planCacheListFilters should succeed with unicode name", + ), + CommandTestCase( + "name_accepts_single_char", + target_collection=NamedCollection(suffix="_x"), + docs=[{"_id": 1}], + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={"filters": [], "ok": 1.0}, + msg="planCacheListFilters should succeed with single-character suffix name", + ), + CommandTestCase( + "name_accepts_underscores", + target_collection=NamedCollection(suffix="_my_test_coll"), + docs=[{"_id": 1}], + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={"filters": [], "ok": 1.0}, + msg="planCacheListFilters should succeed with underscores in name", + ), +] + +# Property [Comment Edge Cases]: planCacheListFilters succeeds with edge-case +# comment values. +LIST_FILTERS_COMMENT_EDGE_CASE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "comment_accepts_long_string", + command=lambda ctx: { + "planCacheListFilters": ctx.collection, + "comment": "x" * 10_000, + }, + expected={"filters": [], "ok": 1.0}, + msg="planCacheListFilters should succeed with very long comment", + ), + CommandTestCase( + "comment_accepts_empty_string", + command=lambda ctx: { + "planCacheListFilters": ctx.collection, + "comment": "", + }, + expected={"filters": [], "ok": 1.0}, + msg="planCacheListFilters should succeed with empty string comment", + ), + CommandTestCase( + "comment_accepts_nested_object", + command=lambda ctx: { + "planCacheListFilters": ctx.collection, + "comment": {"a": {"b": {"c": {"d": {"e": 1}}}}}, + }, + expected={"filters": [], "ok": 1.0}, + msg="planCacheListFilters should succeed with deeply nested object comment", + ), + CommandTestCase( + "comment_accepts_mixed_array", + command=lambda ctx: { + "planCacheListFilters": ctx.collection, + "comment": [1, "two", True, None, {"a": 1}], + }, + expected={"filters": [], "ok": 1.0}, + msg="planCacheListFilters should succeed with array of mixed types as comment", + ), +] + +_BSON_TYPE_VALUES = [ + ("document", {"a": 1}), + ("empty_document", {}), + ("string", "test"), + ("int32", 123), + ("int64", Int64(1)), + ("double", 1.5), + ("decimal128", Decimal128("1")), + ("bool_true", True), + ("bool_false", False), + ("null", None), + ("array", [1, 2]), + ("empty_array", []), + ("binary", Binary(b"\x00")), + ("objectid", ObjectId()), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("regex", Regex(".*")), + ("timestamp", Timestamp(0, 0)), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), +] + +# Property [Comment Type Acceptance]: the comment field accepts any valid +# BSON type. +LIST_FILTERS_COMMENT_TYPE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"comment_{tid}", + command=lambda ctx, v=val: { + "planCacheListFilters": ctx.collection, + "comment": v, + }, + expected={"filters": [], "ok": 1.0}, + msg=f"planCacheListFilters should accept comment of type {tid}", + ) + for tid, val in _BSON_TYPE_VALUES +] + +LIST_FILTERS_CORE_TESTS: list[CommandTestCase] = ( + LIST_FILTERS_BASIC_TESTS + + LIST_FILTERS_CAPPED_TESTS + + LIST_FILTERS_CLUSTERED_TESTS + + LIST_FILTERS_UNKNOWN_FIELD_TESTS + + LIST_FILTERS_NAME_EDGE_CASE_TESTS + + LIST_FILTERS_COMMENT_EDGE_CASE_TESTS + + LIST_FILTERS_COMMENT_TYPE_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(LIST_FILTERS_CORE_TESTS)) +def test_planCacheListFilters_core(database_client, collection, test): + """Test planCacheListFilters command core behavior and field acceptance.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheListFilters/test_planCacheListFilters_errors.py b/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheListFilters/test_planCacheListFilters_errors.py new file mode 100644 index 000000000..33feec3e3 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheListFilters/test_planCacheListFilters_errors.py @@ -0,0 +1,194 @@ +"""Tests for planCacheListFilters command error cases.""" + +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 ( + COMMAND_NOT_SUPPORTED_ON_VIEW_ERROR, + INVALID_NAMESPACE_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.target_collection import ( + TimeseriesCollection, + ViewCollection, +) + +# Property [View Rejection]: planCacheListFilters is not supported on views. +LIST_FILTERS_VIEW_REJECTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "view_rejection", + target_collection=ViewCollection(), + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + error_code=COMMAND_NOT_SUPPORTED_ON_VIEW_ERROR, + msg="planCacheListFilters should be rejected on a view", + ), +] + +# Property [Timeseries Rejection]: planCacheListFilters is not supported on +# timeseries collections. +LIST_FILTERS_TIMESERIES_REJECTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "timeseries_rejection", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + error_code=COMMAND_NOT_SUPPORTED_ON_VIEW_ERROR, + msg="planCacheListFilters should be rejected on a timeseries collection", + ), +] + +# Property [Field Type Rejection]: all non-string BSON types for the +# planCacheListFilters field produce an invalid namespace error. +LIST_FILTERS_NAME_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "name_type_int32", + command={"planCacheListFilters": 123}, + error_code=INVALID_NAMESPACE_ERROR, + msg="planCacheListFilters should reject int32 collection name", + ), + CommandTestCase( + "name_type_int64", + command={"planCacheListFilters": Int64(1)}, + error_code=INVALID_NAMESPACE_ERROR, + msg="planCacheListFilters should reject Int64 collection name", + ), + CommandTestCase( + "name_type_double", + command={"planCacheListFilters": 1.5}, + error_code=INVALID_NAMESPACE_ERROR, + msg="planCacheListFilters should reject double collection name", + ), + CommandTestCase( + "name_type_decimal128", + command={"planCacheListFilters": Decimal128("1")}, + error_code=INVALID_NAMESPACE_ERROR, + msg="planCacheListFilters should reject Decimal128 collection name", + ), + CommandTestCase( + "name_type_bool_true", + command={"planCacheListFilters": True}, + error_code=INVALID_NAMESPACE_ERROR, + msg="planCacheListFilters should reject bool true collection name", + ), + CommandTestCase( + "name_type_bool_false", + command={"planCacheListFilters": False}, + error_code=INVALID_NAMESPACE_ERROR, + msg="planCacheListFilters should reject bool false collection name", + ), + CommandTestCase( + "name_type_null", + command={"planCacheListFilters": None}, + error_code=INVALID_NAMESPACE_ERROR, + msg="planCacheListFilters should reject null collection name", + ), + CommandTestCase( + "name_type_array", + command={"planCacheListFilters": []}, + error_code=INVALID_NAMESPACE_ERROR, + msg="planCacheListFilters should reject array collection name", + ), + CommandTestCase( + "name_type_object", + command={"planCacheListFilters": {}}, + error_code=INVALID_NAMESPACE_ERROR, + msg="planCacheListFilters should reject object collection name", + ), + CommandTestCase( + "name_type_objectid", + command={"planCacheListFilters": ObjectId()}, + error_code=INVALID_NAMESPACE_ERROR, + msg="planCacheListFilters should reject ObjectId collection name", + ), + CommandTestCase( + "name_type_binary", + command={"planCacheListFilters": Binary(b"\x00")}, + error_code=INVALID_NAMESPACE_ERROR, + msg="planCacheListFilters should reject Binary collection name", + ), + CommandTestCase( + "name_type_datetime", + command={"planCacheListFilters": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + error_code=INVALID_NAMESPACE_ERROR, + msg="planCacheListFilters should reject datetime collection name", + ), + CommandTestCase( + "name_type_timestamp", + command={"planCacheListFilters": Timestamp(0, 0)}, + error_code=INVALID_NAMESPACE_ERROR, + msg="planCacheListFilters should reject Timestamp collection name", + ), + CommandTestCase( + "name_type_regex", + command={"planCacheListFilters": Regex(".*")}, + error_code=INVALID_NAMESPACE_ERROR, + msg="planCacheListFilters should reject Regex collection name", + ), + CommandTestCase( + "name_type_code", + command={"planCacheListFilters": Code("function(){}")}, + error_code=INVALID_NAMESPACE_ERROR, + msg="planCacheListFilters should reject Code collection name", + ), + CommandTestCase( + "name_type_minkey", + command={"planCacheListFilters": MinKey()}, + error_code=INVALID_NAMESPACE_ERROR, + msg="planCacheListFilters should reject MinKey collection name", + ), + CommandTestCase( + "name_type_maxkey", + command={"planCacheListFilters": MaxKey()}, + error_code=INVALID_NAMESPACE_ERROR, + msg="planCacheListFilters should reject MaxKey collection name", + ), +] + +# Property [Namespace Edge Cases]: empty string and null byte collection +# names produce an invalid namespace error. +LIST_FILTERS_NAME_EDGE_CASE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "name_empty_string", + command={"planCacheListFilters": ""}, + error_code=INVALID_NAMESPACE_ERROR, + msg="planCacheListFilters should reject empty string collection name", + ), + CommandTestCase( + "name_null_byte_embedded", + command={"planCacheListFilters": "test\x00coll"}, + error_code=INVALID_NAMESPACE_ERROR, + msg="planCacheListFilters should reject null byte in collection name", + ), +] + +LIST_FILTERS_ERROR_TESTS: list[CommandTestCase] = ( + LIST_FILTERS_VIEW_REJECTION_TESTS + + LIST_FILTERS_TIMESERIES_REJECTION_TESTS + + LIST_FILTERS_NAME_TYPE_ERROR_TESTS + + LIST_FILTERS_NAME_EDGE_CASE_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(LIST_FILTERS_ERROR_TESTS)) +def test_planCacheListFilters_errors(database_client, collection, test): + """Test planCacheListFilters command error cases.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheListFilters/test_planCacheListFilters_lifecycle.py b/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheListFilters/test_planCacheListFilters_lifecycle.py new file mode 100644 index 000000000..064cf6dd1 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheListFilters/test_planCacheListFilters_lifecycle.py @@ -0,0 +1,270 @@ +"""Tests for planCacheListFilters filter lifecycle and integration.""" + +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 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, Len + +# Property [Multiple Filters — Two Shapes]: planCacheListFilters returns both +# filters when two different query shapes are set. +LIST_FILTERS_MULTIPLE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "two_shapes", + docs=[{"_id": 1, "a": 1, "b": 1}], + setup=lambda coll: ( + coll.create_index({"a": 1}), + coll.create_index({"b": 1}), + execute_command( + coll, + {"planCacheSetFilter": coll.name, "query": {"a": 1}, "indexes": [{"a": 1}]}, + ), + execute_command( + coll, + {"planCacheSetFilter": coll.name, "query": {"b": 1}, "indexes": [{"b": 1}]}, + ), + ), + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={"filters": Len(2)}, + msg="planCacheListFilters should return 2 filters for 2 query shapes", + ), + CommandTestCase( + "same_query_different_sort", + docs=[{"_id": 1, "a": 1}], + setup=lambda coll: ( + coll.create_index({"a": 1}), + execute_command( + coll, + { + "planCacheSetFilter": coll.name, + "query": {"a": 1}, + "sort": {"a": 1}, + "indexes": [{"a": 1}], + }, + ), + execute_command( + coll, + { + "planCacheSetFilter": coll.name, + "query": {"a": 1}, + "sort": {"a": -1}, + "indexes": [{"a": 1}], + }, + ), + ), + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={"filters": Len(2)}, + msg="planCacheListFilters should return 2 filters when query matches but sort differs", + ), + CommandTestCase( + "three_shapes", + docs=[{"_id": 1, "a": 1, "b": 1, "c": 1}], + setup=lambda coll: ( + coll.create_index({"a": 1}), + coll.create_index({"b": 1}), + coll.create_index({"c": 1}), + execute_command( + coll, + {"planCacheSetFilter": coll.name, "query": {"a": 1}, "indexes": [{"a": 1}]}, + ), + execute_command( + coll, + {"planCacheSetFilter": coll.name, "query": {"b": 1}, "indexes": [{"b": 1}]}, + ), + execute_command( + coll, + {"planCacheSetFilter": coll.name, "query": {"c": 1}, "indexes": [{"c": 1}]}, + ), + ), + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={"filters": Len(3)}, + msg="planCacheListFilters should return 3 filters for 3 query shapes", + ), +] + +# Property [Filter Override]: re-setting a filter for the same query shape +# overrides the previous indexes. +LIST_FILTERS_OVERRIDE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "override_indexes", + docs=[{"_id": 1, "a": 1, "b": 1}], + setup=lambda coll: ( + coll.create_index({"a": 1}), + coll.create_index({"a": 1, "b": 1}), + execute_command( + coll, + {"planCacheSetFilter": coll.name, "query": {"a": 1}, "indexes": [{"a": 1}]}, + ), + execute_command( + coll, + { + "planCacheSetFilter": coll.name, + "query": {"a": 1}, + "indexes": [{"a": 1, "b": 1}], + }, + ), + ), + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={ + "filters": Len(1), + "filters.0.indexes": Eq([{"a": 1, "b": 1}]), + }, + msg="planCacheListFilters should have 1 filter with overridden indexes", + ), +] + +# Property [Lifecycle — Empty Before Set]: planCacheListFilters returns empty +# filters before any filters are set. +LIST_FILTERS_EMPTY_BEFORE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "returns_empty_before_any_set", + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={"filters": Eq([]), "ok": Eq(1.0)}, + msg="planCacheListFilters should return empty filters initially", + ), +] + +# Property [Lifecycle — Present After Set]: planCacheListFilters returns the +# filter after planCacheSetFilter is called. +LIST_FILTERS_PRESENT_AFTER_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "returns_filter_after_set", + docs=[{"_id": 1, "a": 1}], + setup=lambda coll: ( + coll.create_index({"a": 1}), + execute_command( + coll, + {"planCacheSetFilter": coll.name, "query": {"a": 1}, "indexes": [{"a": 1}]}, + ), + ), + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={"filters": Len(1)}, + msg="planCacheListFilters should have 1 filter after set", + ), +] + +# Property [Lifecycle — Empty After Clear All]: planCacheListFilters returns +# empty filters after planCacheClearFilters clears all filters. +LIST_FILTERS_CLEAR_ALL_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "returns_empty_after_clear_all", + docs=[{"_id": 1, "a": 1}], + setup=lambda coll: ( + coll.create_index({"a": 1}), + execute_command( + coll, + {"planCacheSetFilter": coll.name, "query": {"a": 1}, "indexes": [{"a": 1}]}, + ), + execute_command(coll, {"planCacheClearFilters": coll.name}), + ), + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={"filters": Eq([]), "ok": Eq(1.0)}, + msg="planCacheListFilters should return empty filters after clear all", + ), +] + +# Property [Lifecycle — Remaining After Selective Clear]: after clearing one +# query shape, the remaining filter is still returned. +LIST_FILTERS_SELECTIVE_CLEAR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "returns_remaining_after_selective_clear", + docs=[{"_id": 1, "a": 1, "b": 1}], + setup=lambda coll: ( + coll.create_index({"a": 1}), + coll.create_index({"b": 1}), + execute_command( + coll, + {"planCacheSetFilter": coll.name, "query": {"a": 1}, "indexes": [{"a": 1}]}, + ), + execute_command( + coll, + {"planCacheSetFilter": coll.name, "query": {"b": 1}, "indexes": [{"b": 1}]}, + ), + execute_command( + coll, + {"planCacheClearFilters": coll.name, "query": {"a": 1}}, + ), + ), + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={ + "filters": Len(1), + "filters.0.query": Eq({"b": 1}), + }, + msg="planCacheListFilters should have 1 remaining filter for query {b: 1}", + ), +] + +# Property [Index Lifecycle]: filters persist even after the referenced +# index is dropped. +LIST_FILTERS_INDEX_DROPPED_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "filter_persists_after_index_dropped", + docs=[{"_id": 1, "a": 1}], + setup=lambda coll: ( + coll.create_index({"a": 1}, name="a_1"), + execute_command( + coll, + {"planCacheSetFilter": coll.name, "query": {"a": 1}, "indexes": [{"a": 1}]}, + ), + coll.drop_index("a_1"), + ), + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={ + "filters": Len(1), + "filters.0.query": Eq({"a": 1}), + }, + msg="planCacheListFilters should still return filter after index is dropped", + ), +] + +LIST_FILTERS_LIFECYCLE_TESTS: list[CommandTestCase] = ( + LIST_FILTERS_MULTIPLE_TESTS + + LIST_FILTERS_OVERRIDE_TESTS + + LIST_FILTERS_EMPTY_BEFORE_TESTS + + LIST_FILTERS_PRESENT_AFTER_TESTS + + LIST_FILTERS_CLEAR_ALL_TESTS + + LIST_FILTERS_SELECTIVE_CLEAR_TESTS + + LIST_FILTERS_INDEX_DROPPED_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(LIST_FILTERS_LIFECYCLE_TESTS)) +def test_planCacheListFilters_lifecycle(database_client, collection, test): + """Test planCacheListFilters filter lifecycle and integration.""" + collection = test.prepare(database_client, collection) + if test.setup: + test.setup(collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertProperties(result, test.build_expected(ctx), msg=test.msg, raw_res=True) + + +# Property [Collection Isolation]: filters set on collection A are not +# visible when listing filters on collection B. +def test_planCacheListFilters_collection_isolation(database_client, collection): + """Test planCacheListFilters is scoped to the specific collection.""" + collection.insert_one({"_id": 1, "a": 1}) + collection.create_index({"a": 1}) + execute_command( + collection, + {"planCacheSetFilter": collection.name, "query": {"a": 1}, "indexes": [{"a": 1}]}, + ) + + other_coll = database_client.create_collection(f"{collection.name}_other") + try: + result = execute_command(other_coll, {"planCacheListFilters": other_coll.name}) + assertResult( + result, + expected={"filters": [], "ok": 1.0}, + msg="planCacheListFilters should not show filters from another collection", + raw_res=True, + ) + finally: + database_client.drop_collection(other_coll.name) diff --git a/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheListFilters/test_planCacheListFilters_response.py b/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheListFilters/test_planCacheListFilters_response.py new file mode 100644 index 000000000..f6d771464 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheListFilters/test_planCacheListFilters_response.py @@ -0,0 +1,350 @@ +"""Tests for planCacheListFilters response structure and filter content.""" + +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 assertProperties +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import ( + ContainsElement, + Eq, + Exists, + IsType, + Len, +) + +# Property [Ok Field Type]: planCacheListFilters ok field is of type double. +LIST_FILTERS_OK_TYPE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "ok_type_double", + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={"ok": IsType("double")}, + msg="planCacheListFilters ok field should be of type double", + ), +] + +# Property [Filters Field Type]: planCacheListFilters filters field is of +# type array even when empty. +LIST_FILTERS_ARRAY_TYPE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "filters_type_array", + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={"filters": IsType("array")}, + msg="planCacheListFilters filters field should be of type array", + ), +] + +# Property [Single Filter Entry]: after setting one filter, the response +# contains one entry with matching query, sort, projection, and indexes. +LIST_FILTERS_SINGLE_ENTRY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "single_filter_entry", + docs=[{"_id": 1, "a": 1}], + setup=lambda coll: ( + coll.create_index({"a": 1}), + execute_command( + coll, + {"planCacheSetFilter": coll.name, "query": {"a": 1}, "indexes": [{"a": 1}]}, + ), + ), + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={ + "filters": Len(1), + "filters.0.query": Eq({"a": 1}), + "filters.0.indexes": Eq([{"a": 1}]), + }, + msg="planCacheListFilters should return one filter entry with matching query and indexes", + ), + CommandTestCase( + "same_shape_different_value", + docs=[{"_id": 1, "a": 1}], + setup=lambda coll: ( + coll.create_index({"a": 1}), + execute_command( + coll, + {"planCacheSetFilter": coll.name, "query": {"a": 1}, "indexes": [{"a": 1}]}, + ), + execute_command( + coll, + {"planCacheSetFilter": coll.name, "query": {"a": 999}, "indexes": [{"a": 1}]}, + ), + ), + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={"filters": Len(1)}, + msg="planCacheListFilters should return 1 filter when queries differ only in value", + ), +] + +# Property [Default Sort]: filter entry sort is empty document when sort was +# not specified in planCacheSetFilter. +# Property [Default Projection]: filter entry projection is empty document +# when projection was not specified in planCacheSetFilter. +LIST_FILTERS_DEFAULTS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "default_sort", + docs=[{"_id": 1, "a": 1}], + setup=lambda coll: ( + coll.create_index({"a": 1}), + execute_command( + coll, + {"planCacheSetFilter": coll.name, "query": {"a": 1}, "indexes": [{"a": 1}]}, + ), + ), + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={"filters.0.sort": Eq({})}, + msg="planCacheListFilters should default sort to empty document", + ), + CommandTestCase( + "default_projection", + docs=[{"_id": 1, "a": 1}], + setup=lambda coll: ( + coll.create_index({"a": 1}), + execute_command( + coll, + {"planCacheSetFilter": coll.name, "query": {"a": 1}, "indexes": [{"a": 1}]}, + ), + ), + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={"filters.0.projection": Eq({})}, + msg="planCacheListFilters should default projection to empty document", + ), +] + +# Property [Sort In Entry]: after setting a filter with sort, the filter +# entry includes the sort document. +# Property [Projection In Entry]: after setting a filter with projection, +# the filter entry includes the projection document. +LIST_FILTERS_SHAPE_FIELDS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "sort_in_entry", + docs=[{"_id": 1, "a": 1, "b": 1}], + setup=lambda coll: ( + coll.create_index({"a": 1, "b": 1}), + execute_command( + coll, + { + "planCacheSetFilter": coll.name, + "query": {"a": 1}, + "sort": {"b": 1}, + "indexes": [{"a": 1, "b": 1}], + }, + ), + ), + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={"filters.0.sort": Eq({"b": 1})}, + msg="planCacheListFilters should return sort matching planCacheSetFilter", + ), + CommandTestCase( + "projection_in_entry", + docs=[{"_id": 1, "a": 1}], + setup=lambda coll: ( + coll.create_index({"a": 1}), + execute_command( + coll, + { + "planCacheSetFilter": coll.name, + "query": {"a": 1}, + "projection": {"a": 1}, + "indexes": [{"a": 1}], + }, + ), + ), + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={"filters.0.projection": Eq({"a": 1})}, + msg="planCacheListFilters should return projection matching planCacheSetFilter", + ), +] + +# Property [Collation In Entry]: after setting a filter with collation, the +# filter entry includes the collation document. +LIST_FILTERS_COLLATION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "collation_basic", + docs=[{"_id": 1, "a": 1}], + setup=lambda coll: ( + coll.create_index({"a": 1}), + execute_command( + coll, + { + "planCacheSetFilter": coll.name, + "query": {"a": 1}, + "collation": {"locale": "en"}, + "indexes": [{"a": 1}], + }, + ), + ), + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={ + "filters.0.collation": Exists(), + "filters.0.collation.locale": Eq("en"), + }, + msg="planCacheListFilters should return collation with locale en", + ), + CommandTestCase( + "collation_full", + docs=[{"_id": 1, "a": 1}], + setup=lambda coll: ( + coll.create_index({"a": 1}), + execute_command( + coll, + { + "planCacheSetFilter": coll.name, + "query": {"a": 1}, + "collation": {"locale": "fr", "strength": 2}, + "indexes": [{"a": 1}], + }, + ), + ), + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={ + "filters.0.collation.locale": Eq("fr"), + "filters.0.collation.strength": Eq(2), + }, + msg="planCacheListFilters should return collation with locale fr and strength 2", + ), +] + +# Property [All Shape Parameters]: after setting a filter with all shape +# parameters, all fields are present in the filter entry. +LIST_FILTERS_ALL_PARAMS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "all_shape_params", + docs=[{"_id": 1, "a": 1, "b": 1}], + setup=lambda coll: ( + coll.create_index({"a": 1}), + execute_command( + coll, + { + "planCacheSetFilter": coll.name, + "query": {"a": 1}, + "sort": {"b": 1}, + "projection": {"a": 1}, + "collation": {"locale": "en"}, + "indexes": [{"a": 1}], + }, + ), + ), + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={ + "filters.0.query": Eq({"a": 1}), + "filters.0.sort": Eq({"b": 1}), + "filters.0.projection": Eq({"a": 1}), + "filters.0.collation": Exists(), + "filters.0.indexes": Eq([{"a": 1}]), + }, + msg="planCacheListFilters should return all shape parameters in filter entry", + ), +] + +# Property [Multiple Indexes]: filter entry indexes field contains all +# indexes specified in planCacheSetFilter. +LIST_FILTERS_MULTI_INDEX_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "entry_indexes_contains_all_specified", + docs=[{"_id": 1, "a": 1, "b": 1}], + setup=lambda coll: ( + coll.create_index({"a": 1}), + coll.create_index({"a": 1, "b": 1}), + execute_command( + coll, + { + "planCacheSetFilter": coll.name, + "query": {"a": 1}, + "indexes": [{"a": 1}, {"a": 1, "b": 1}], + }, + ), + ), + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={"filters.0.indexes": Len(2)}, + msg="planCacheListFilters should return both specified indexes", + ), +] + +# Property [Filter Entry Field Types]: query, sort, projection are documents +# and indexes is an array. +LIST_FILTERS_ENTRY_TYPES_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "entry_fields_have_correct_types", + docs=[{"_id": 1, "a": 1}], + setup=lambda coll: ( + coll.create_index({"a": 1}), + execute_command( + coll, + {"planCacheSetFilter": coll.name, "query": {"a": 1}, "indexes": [{"a": 1}]}, + ), + ), + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={ + "filters.0.query": IsType("object"), + "filters.0.sort": IsType("object"), + "filters.0.projection": IsType("object"), + "filters.0.indexes": IsType("array"), + }, + msg="planCacheListFilters filter entry fields should have correct types", + ), +] + +# Property [Index By Name]: after setting a filter with index specified by +# name, the name is returned in the indexes array. +# Property [Index By Key Pattern]: after setting a filter with index +# specified by key pattern, the key pattern is returned in the indexes array. +LIST_FILTERS_INDEX_SPEC_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "index_by_name", + docs=[{"_id": 1, "a": 1}], + setup=lambda coll: ( + coll.create_index({"a": 1}, name="a_1"), + execute_command( + coll, + {"planCacheSetFilter": coll.name, "query": {"a": 1}, "indexes": ["a_1"]}, + ), + ), + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={"filters.0.indexes": ContainsElement("a_1")}, + msg="planCacheListFilters should return index name in indexes array", + ), + CommandTestCase( + "index_by_key_pattern", + docs=[{"_id": 1, "a": 1}], + setup=lambda coll: ( + coll.create_index({"a": 1}), + execute_command( + coll, + {"planCacheSetFilter": coll.name, "query": {"a": 1}, "indexes": [{"a": 1}]}, + ), + ), + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={"filters.0.indexes": ContainsElement({"a": 1})}, + msg="planCacheListFilters should return key pattern in indexes array", + ), +] + +LIST_FILTERS_RESPONSE_TESTS: list[CommandTestCase] = ( + LIST_FILTERS_OK_TYPE_TESTS + + LIST_FILTERS_ARRAY_TYPE_TESTS + + LIST_FILTERS_SINGLE_ENTRY_TESTS + + LIST_FILTERS_DEFAULTS_TESTS + + LIST_FILTERS_SHAPE_FIELDS_TESTS + + LIST_FILTERS_COLLATION_TESTS + + LIST_FILTERS_ALL_PARAMS_TESTS + + LIST_FILTERS_MULTI_INDEX_TESTS + + LIST_FILTERS_ENTRY_TYPES_TESTS + + LIST_FILTERS_INDEX_SPEC_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(LIST_FILTERS_RESPONSE_TESTS)) +def test_planCacheListFilters_response(database_client, collection, test): + """Test planCacheListFilters response structure and filter content.""" + collection = test.prepare(database_client, collection) + if test.setup: + test.setup(collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertProperties(result, test.build_expected(ctx), msg=test.msg, raw_res=True) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_argument_validation.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_argument_validation.py new file mode 100644 index 000000000..775a74499 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_argument_validation.py @@ -0,0 +1,113 @@ +"""Tests for bulkWrite argument acceptance — valid ops, nsInfo, and optional fields.""" + +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 + +BULKWRITE_ARGUMENT_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "ops_single_operation", + command={"bulkWrite": 1, "ops": [{"insert": 0, "document": {"_id": 1}}]}, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should accept ops with a single operation", + ), + CommandTestCase( + "ops_many_operations", + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": i}} for i in range(50)], + }, + expected={"ok": 1.0, "nInserted": 50}, + msg="bulkWrite should accept ops with many operations", + ), + CommandTestCase( + "let_with_document", + docs=[{"_id": 1, "x": 10}], + command={ + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"$expr": {"$eq": ["$x", "$$targetVal"]}}, + "updateMods": {"$set": {"matched": True}}, + } + ], + "let": {"targetVal": 10}, + }, + expected={"ok": 1.0, "nMatched": 1, "nModified": 1}, + msg="bulkWrite should accept a let document with variables", + ), + CommandTestCase( + "ops_only_updates", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 2}], + command={ + "bulkWrite": 1, + "ops": [ + {"update": 0, "filter": {"_id": 1}, "updateMods": {"$set": {"x": 10}}}, + {"update": 0, "filter": {"_id": 2}, "updateMods": {"$set": {"x": 20}}}, + ], + }, + expected={"ok": 1.0, "nMatched": 2, "nModified": 2}, + msg="bulkWrite should accept an ops array of only updates", + ), + CommandTestCase( + "ops_only_deletes", + docs=[{"_id": 1}, {"_id": 2}], + command={ + "bulkWrite": 1, + "ops": [ + {"delete": 0, "filter": {"_id": 1}}, + {"delete": 0, "filter": {"_id": 2}}, + ], + }, + expected={"ok": 1.0, "nDeleted": 2}, + msg="bulkWrite should accept an ops array of only deletes", + ), + CommandTestCase( + "writeConcern_valid", + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "writeConcern": {"w": 1}, + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should accept a valid writeConcern", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(BULKWRITE_ARGUMENT_TESTS)) +def test_bulkWrite_argument_validation(database_client, collection, test): + """Test bulkWrite argument acceptance — valid ops, nsInfo, and optional fields.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + command = test.build_command(ctx) + if "nsInfo" not in command: + command = {**command, "nsInfo": [{"ns": ctx.namespace}]} + result = execute_admin_command(collection, command) + assertSuccessPartial(result, test.build_expected(ctx), msg=test.msg) + + +def test_bulkWrite_accepts_unused_nsInfo_entry(collection): + """Test bulkWrite accepts an nsInfo array with more namespaces than the ops reference.""" + ns = f"{collection.database.name}.{collection.name}" + ns_unused = f"{collection.database.name}.{collection.name}_unused" + result = execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "nsInfo": [{"ns": ns}, {"ns": ns_unused}], + }, + ) + assertSuccessPartial( + result, + {"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should accept an nsInfo entry that no op references", + ) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_bson_type_validation.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_bson_type_validation.py new file mode 100644 index 000000000..6657bb102 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_bson_type_validation.py @@ -0,0 +1,380 @@ +"""Tests for bulkWrite BSON type validation of command, operation, and namespace fields. + +Verifies that each bulkWrite input field accepts its valid BSON types and rejects +all other types with the correct error code, using the shared BSON type harness. +""" + +from typing import Any + +import pytest + +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 ( + FAILED_TO_PARSE_ERROR, + MISSING_FIELD_ERROR, + TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.test_constants import ( + DECIMAL128_ZERO, + DOUBLE_ZERO, + INT32_ZERO, + INT64_ZERO, +) + +COMMAND_FIELD_PARAMS: list[BsonTypeTestCase] = [ + BsonTypeTestCase( + id="command_bulkWrite", + keyword="bulkWrite", + msg="bulkWrite dispatches on the first field being named 'bulkWrite'; its value is " + "ignored, so the command field accepts any BSON type", + valid_types=list(BsonType), + ), + BsonTypeTestCase( + id="command_ordered", + keyword="ordered", + msg="bulkWrite ordered accepts only bool and null", + valid_types=[BsonType.BOOL, BsonType.NULL], + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="command_bypassDocumentValidation", + keyword="bypassDocumentValidation", + msg="bulkWrite bypassDocumentValidation accepts bool, null, and numeric types", + valid_types=[ + BsonType.BOOL, + BsonType.NULL, + BsonType.DOUBLE, + BsonType.INT, + BsonType.LONG, + BsonType.DECIMAL, + ], + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="command_comment", + keyword="comment", + msg="bulkWrite comment accepts any BSON type", + valid_types=list(BsonType), + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="command_let", + keyword="let", + msg="bulkWrite let accepts only a document or null", + valid_types=[BsonType.OBJECT, BsonType.NULL], + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="command_errorsOnly", + keyword="errorsOnly", + msg="bulkWrite errorsOnly accepts only bool and null", + valid_types=[BsonType.BOOL, BsonType.NULL], + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="command_cursor", + keyword="cursor", + msg="bulkWrite cursor accepts only a document or null", + valid_types=[BsonType.OBJECT, BsonType.NULL], + default_error_code=TYPE_MISMATCH_ERROR, + valid_inputs={BsonType.OBJECT: {"batchSize": 1}}, + ), + BsonTypeTestCase( + id="command_writeConcern", + keyword="writeConcern", + msg="bulkWrite writeConcern accepts only a document or null", + valid_types=[BsonType.OBJECT, BsonType.NULL], + default_error_code=TYPE_MISMATCH_ERROR, + valid_inputs={BsonType.OBJECT: {"w": 1}}, + ), +] + +NAMESPACE_PARAMS: list[BsonTypeTestCase] = [ + BsonTypeTestCase( + id="command_nsInfo", + msg="bulkWrite nsInfo accepts only an array", + valid_types=[BsonType.ARRAY], + default_error_code=TYPE_MISMATCH_ERROR, + error_code_overrides={BsonType.NULL: MISSING_FIELD_ERROR}, + ), + BsonTypeTestCase( + id="nsInfo_ns", + msg="bulkWrite nsInfo.ns accepts only a string", + valid_types=[BsonType.STRING], + default_error_code=TYPE_MISMATCH_ERROR, + error_code_overrides={BsonType.NULL: MISSING_FIELD_ERROR}, + ), +] + +INSERT_OP_PARAMS: list[BsonTypeTestCase] = [ + BsonTypeTestCase( + id="op_insert_index", + msg="bulkWrite insert namespace index accepts only numeric types", + valid_types=[BsonType.DOUBLE, BsonType.INT, BsonType.LONG, BsonType.DECIMAL], + default_error_code=TYPE_MISMATCH_ERROR, + error_code_overrides={BsonType.NULL: MISSING_FIELD_ERROR}, + valid_inputs={ + BsonType.DOUBLE: DOUBLE_ZERO, + BsonType.INT: INT32_ZERO, + BsonType.LONG: INT64_ZERO, + BsonType.DECIMAL: DECIMAL128_ZERO, + }, + ), + BsonTypeTestCase( + id="op_document", + msg="bulkWrite insert document accepts only a document", + valid_types=[BsonType.OBJECT], + default_error_code=TYPE_MISMATCH_ERROR, + error_code_overrides={BsonType.NULL: MISSING_FIELD_ERROR}, + ), +] + +UPDATE_OP_PARAMS: list[BsonTypeTestCase] = [ + BsonTypeTestCase( + id="op_update_index", + msg="bulkWrite update namespace index accepts only numeric types", + valid_types=[BsonType.DOUBLE, BsonType.INT, BsonType.LONG, BsonType.DECIMAL], + default_error_code=TYPE_MISMATCH_ERROR, + error_code_overrides={BsonType.NULL: MISSING_FIELD_ERROR}, + valid_inputs={ + BsonType.DOUBLE: DOUBLE_ZERO, + BsonType.INT: INT32_ZERO, + BsonType.LONG: INT64_ZERO, + BsonType.DECIMAL: DECIMAL128_ZERO, + }, + ), + BsonTypeTestCase( + id="op_filter", + msg="bulkWrite update filter accepts only a document", + valid_types=[BsonType.OBJECT], + default_error_code=TYPE_MISMATCH_ERROR, + error_code_overrides={BsonType.NULL: MISSING_FIELD_ERROR}, + ), + BsonTypeTestCase( + id="op_updateMods", + msg="bulkWrite updateMods accepts a document or an aggregation pipeline array", + valid_types=[BsonType.OBJECT, BsonType.ARRAY], + default_error_code=FAILED_TO_PARSE_ERROR, + valid_inputs={BsonType.ARRAY: [{"$set": {"x": 1}}]}, + ), + BsonTypeTestCase( + id="op_arrayFilters", + msg="bulkWrite arrayFilters accepts only an array or null", + valid_types=[BsonType.ARRAY, BsonType.NULL], + default_error_code=TYPE_MISMATCH_ERROR, + valid_inputs={BsonType.ARRAY: []}, + ), + BsonTypeTestCase( + id="op_multi", + msg="bulkWrite multi accepts only bool and null", + valid_types=[BsonType.BOOL, BsonType.NULL], + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="op_hint", + msg="bulkWrite hint accepts only a string or a document", + valid_types=[BsonType.STRING, BsonType.OBJECT], + default_error_code=FAILED_TO_PARSE_ERROR, + ), + BsonTypeTestCase( + id="op_constants", + msg="bulkWrite constants accepts only a document or null", + valid_types=[BsonType.OBJECT, BsonType.NULL], + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="op_collation", + msg="bulkWrite collation accepts only a document or null", + valid_types=[BsonType.OBJECT, BsonType.NULL], + default_error_code=TYPE_MISMATCH_ERROR, + valid_inputs={BsonType.OBJECT: {"locale": "en"}}, + ), + BsonTypeTestCase( + id="op_upsert", + msg="bulkWrite upsert accepts only bool and null", + valid_types=[BsonType.BOOL, BsonType.NULL], + default_error_code=TYPE_MISMATCH_ERROR, + ), +] + +DELETE_OP_PARAMS: list[BsonTypeTestCase] = [ + BsonTypeTestCase( + id="op_delete_index", + msg="bulkWrite delete namespace index accepts only numeric types", + valid_types=[BsonType.DOUBLE, BsonType.INT, BsonType.LONG, BsonType.DECIMAL], + default_error_code=TYPE_MISMATCH_ERROR, + error_code_overrides={BsonType.NULL: MISSING_FIELD_ERROR}, + valid_inputs={ + BsonType.DOUBLE: DOUBLE_ZERO, + BsonType.INT: INT32_ZERO, + BsonType.LONG: INT64_ZERO, + BsonType.DECIMAL: DECIMAL128_ZERO, + }, + ), + BsonTypeTestCase( + id="op_delete_filter", + msg="bulkWrite delete filter accepts only a document", + valid_types=[BsonType.OBJECT], + default_error_code=TYPE_MISMATCH_ERROR, + error_code_overrides={BsonType.NULL: MISSING_FIELD_ERROR}, + ), + BsonTypeTestCase( + id="op_delete_multi", + msg="bulkWrite delete multi accepts only bool and null", + valid_types=[BsonType.BOOL, BsonType.NULL], + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="op_delete_hint", + msg="bulkWrite delete hint accepts only a string or a document", + valid_types=[BsonType.STRING, BsonType.OBJECT], + default_error_code=FAILED_TO_PARSE_ERROR, + ), + BsonTypeTestCase( + id="op_delete_collation", + msg="bulkWrite delete collation accepts only a document or null", + valid_types=[BsonType.OBJECT, BsonType.NULL], + default_error_code=TYPE_MISMATCH_ERROR, + valid_inputs={BsonType.OBJECT: {"locale": "en"}}, + ), +] + +BULKWRITE_TYPE_PARAMS: list[BsonTypeTestCase] = ( + COMMAND_FIELD_PARAMS + NAMESPACE_PARAMS + INSERT_OP_PARAMS + UPDATE_OP_PARAMS + DELETE_OP_PARAMS +) + + +def _inject_command_nsInfo( + command: dict[str, Any], bson_type: BsonType, sample_value: Any, namespace: str +) -> None: + """Replace the whole ``nsInfo`` array (valid ARRAY uses the live namespace).""" + command["nsInfo"] = [{"ns": namespace}] if bson_type == BsonType.ARRAY else sample_value + + +def _inject_nsInfo_ns( + command: dict[str, Any], bson_type: BsonType, sample_value: Any, namespace: str +) -> None: + """Set ``nsInfo[0].ns`` (valid STRING uses the live namespace).""" + ns_val = namespace if bson_type == BsonType.STRING else sample_value + command["nsInfo"] = [{"ns": ns_val}] + + +def _inject_op_field(command: dict[str, Any], spec_id: str, sample_value: Any) -> None: + """Build the op under test with ``sample_value`` and set it as ``ops[0]``. + + Field order matters: the op discriminator (insert/update/delete) must be first, so + ``sample_value`` leads the ``*_index`` specs. + """ + ops_by_spec: dict[str, dict[str, Any]] = { + "op_insert_index": {"insert": sample_value, "document": {"_id": 1}}, + "op_document": {"insert": 0, "document": sample_value}, + "op_update_index": {"update": sample_value, "filter": {}, "updateMods": {"$set": {"x": 1}}}, + "op_filter": {"update": 0, "filter": sample_value, "updateMods": {"$set": {"x": 1}}}, + "op_updateMods": {"update": 0, "filter": {}, "updateMods": sample_value}, + "op_arrayFilters": { + "update": 0, + "filter": {}, + "updateMods": {"$set": {"x": 1}}, + "arrayFilters": sample_value, + }, + "op_multi": { + "update": 0, + "filter": {}, + "updateMods": {"$set": {"x": 1}}, + "multi": sample_value, + }, + "op_hint": { + "update": 0, + "filter": {}, + "updateMods": {"$set": {"x": 1}}, + "hint": sample_value, + }, + "op_constants": { + "update": 0, + "filter": {}, + "updateMods": [{"$set": {"x": 1}}], + "constants": sample_value, + }, + "op_collation": { + "update": 0, + "filter": {}, + "updateMods": {"$set": {"x": 1}}, + "collation": sample_value, + }, + "op_upsert": { + "update": 0, + "filter": {}, + "updateMods": {"$set": {"x": 1}}, + "upsert": sample_value, + }, + "op_delete_index": {"delete": sample_value, "filter": {}}, + "op_delete_filter": {"delete": 0, "filter": sample_value}, + "op_delete_multi": {"delete": 0, "filter": {}, "multi": sample_value}, + "op_delete_hint": {"delete": 0, "filter": {}, "hint": sample_value}, + "op_delete_collation": {"delete": 0, "filter": {}, "collation": sample_value}, + } + command["ops"] = [ops_by_spec[spec_id]] + + +def _inject_top_level(command: dict[str, Any], spec: BsonTypeTestCase, sample_value: Any) -> None: + """Set a top-level command field directly on the command document.""" + if spec.keyword is None: + raise ValueError(f"top-level spec {spec.id!r} must define a keyword") + command[spec.keyword] = sample_value + + +def _build_command( + spec: BsonTypeTestCase, bson_type: BsonType, sample_value: Any, namespace: str +) -> dict[str, Any]: + """Build a bulkWrite command injecting ``sample_value`` into the field named by ``spec``.""" + command: dict[str, Any] = { + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "nsInfo": [{"ns": namespace}], + } + + if spec.id == "command_nsInfo": + _inject_command_nsInfo(command, bson_type, sample_value, namespace) + elif spec.id == "nsInfo_ns": + _inject_nsInfo_ns(command, bson_type, sample_value, namespace) + elif spec.id.startswith("op_"): + _inject_op_field(command, spec.id, sample_value) + else: + _inject_top_level(command, spec, sample_value) + + return command + + +@pytest.mark.parametrize( + "bson_type,sample_value,spec", generate_bson_rejection_test_cases(BULKWRITE_TYPE_PARAMS) +) +def test_bulkWrite_bson_type_rejected(collection, bson_type, sample_value, spec): + """Test bulkWrite rejects invalid BSON types for each input field with the correct code.""" + namespace = f"{collection.database.name}.{collection.name}" + result = execute_admin_command( + collection, _build_command(spec, bson_type, sample_value, namespace) + ) + assertFailureCode( + result, spec.expected_code(bson_type), msg=f"{spec.msg}: rejects {bson_type.value}" + ) + + +@pytest.mark.parametrize( + "bson_type,sample_value,spec", generate_bson_acceptance_test_cases(BULKWRITE_TYPE_PARAMS) +) +def test_bulkWrite_bson_type_accepted(collection, bson_type, sample_value, spec): + """Test bulkWrite accepts valid BSON types for each input field.""" + namespace = f"{collection.database.name}.{collection.name}" + result = execute_admin_command( + collection, _build_command(spec, bson_type, sample_value, namespace) + ) + assertSuccessPartial( + result, {"ok": 1.0, "nErrors": 0}, msg=f"{spec.msg}: accepts {bson_type.value}" + ) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_bypass_validation.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_bypass_validation.py new file mode 100644 index 000000000..243c9d988 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_bypass_validation.py @@ -0,0 +1,389 @@ +"""Tests for bulkWrite bypassDocumentValidation value coercion and type rejection. + +Mirrors ``aggregate/test_aggregate_bypass_validation.py``. bulkWrite-specific: a validator +failure surfaces as an op-level error (``nErrors:1``, ``cursor.firstBatch.0.code`` 121). +""" + +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 ( + DOCUMENT_VALIDATION_FAILURE_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 +from documentdb_tests.framework.target_collection import CustomCollection +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_NAN, + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ZERO, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + FLOAT_NEGATIVE_NAN, + INT32_ZERO, +) + +_VALIDATOR = {"$jsonSchema": {"bsonType": "object", "required": ["name"]}} + + +def _validated_collection() -> CustomCollection: + return CustomCollection(options={"validator": _VALIDATOR}) + + +_BYPASS = {"ok": Eq(1.0), "nErrors": Eq(0), "nInserted": Eq(1)} + +BULKWRITE_BYPASS_TRUTHY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "bypass_truthy_true", + target_collection=_validated_collection(), + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "bypassDocumentValidation": True, + }, + expected=_BYPASS, + msg="bulkWrite bypassDocumentValidation=true (truthy) should bypass the validator", + ), + CommandTestCase( + "bypass_truthy_int32_1", + target_collection=_validated_collection(), + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "bypassDocumentValidation": 1, + }, + expected=_BYPASS, + msg="bulkWrite bypassDocumentValidation=int32 1 (truthy) should bypass the validator", + ), + CommandTestCase( + "bypass_truthy_int64_1", + target_collection=_validated_collection(), + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "bypassDocumentValidation": Int64(1), + }, + expected=_BYPASS, + msg="bulkWrite bypassDocumentValidation=Int64 1 (truthy) should bypass the validator", + ), + CommandTestCase( + "bypass_truthy_double_1", + target_collection=_validated_collection(), + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "bypassDocumentValidation": 1.0, + }, + expected=_BYPASS, + msg="bulkWrite bypassDocumentValidation=double 1.0 (truthy) should bypass the validator", + ), + CommandTestCase( + "bypass_truthy_decimal128_1", + target_collection=_validated_collection(), + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "bypassDocumentValidation": Decimal128("1"), + }, + expected=_BYPASS, + msg="bulkWrite bypassDocumentValidation=Decimal128 1 (truthy) should bypass the validator", + ), + CommandTestCase( + "bypass_truthy_nan", + target_collection=_validated_collection(), + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "bypassDocumentValidation": FLOAT_NAN, + }, + expected=_BYPASS, + msg="bulkWrite bypassDocumentValidation=NaN (truthy) should bypass the validator", + ), + CommandTestCase( + "bypass_truthy_neg_nan", + target_collection=_validated_collection(), + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "bypassDocumentValidation": FLOAT_NEGATIVE_NAN, + }, + expected=_BYPASS, + msg="bulkWrite bypassDocumentValidation=-NaN (truthy) should bypass the validator", + ), + CommandTestCase( + "bypass_truthy_decimal128_nan", + target_collection=_validated_collection(), + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "bypassDocumentValidation": DECIMAL128_NAN, + }, + expected=_BYPASS, + msg="bulkWrite bypassDocumentValidation=Decimal128 NaN (truthy) should bypass", + ), + CommandTestCase( + "bypass_truthy_decimal128_neg_nan", + target_collection=_validated_collection(), + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "bypassDocumentValidation": DECIMAL128_NEGATIVE_NAN, + }, + expected=_BYPASS, + msg="bulkWrite bypassDocumentValidation=Decimal128 -NaN (truthy) should bypass", + ), + CommandTestCase( + "bypass_truthy_infinity", + target_collection=_validated_collection(), + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "bypassDocumentValidation": FLOAT_INFINITY, + }, + expected=_BYPASS, + msg="bulkWrite bypassDocumentValidation=Infinity (truthy) should bypass the validator", + ), + CommandTestCase( + "bypass_truthy_neg_infinity", + target_collection=_validated_collection(), + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "bypassDocumentValidation": FLOAT_NEGATIVE_INFINITY, + }, + expected=_BYPASS, + msg="bulkWrite bypassDocumentValidation=-Infinity (truthy) should bypass the validator", + ), + CommandTestCase( + "bypass_truthy_decimal128_infinity", + target_collection=_validated_collection(), + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "bypassDocumentValidation": DECIMAL128_INFINITY, + }, + expected=_BYPASS, + msg="bulkWrite bypassDocumentValidation=Decimal128 Infinity (truthy) should bypass", + ), + CommandTestCase( + "bypass_truthy_decimal128_neg_infinity", + target_collection=_validated_collection(), + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "bypassDocumentValidation": DECIMAL128_NEGATIVE_INFINITY, + }, + expected=_BYPASS, + msg="bulkWrite bypassDocumentValidation=Decimal128 -Infinity (truthy) should bypass", + ), +] + +_ENFORCE = { + "ok": Eq(1.0), + "nErrors": Eq(1), + "nInserted": Eq(0), + "cursor.firstBatch.0.ok": Eq(0.0), + "cursor.firstBatch.0.idx": Eq(0), + "cursor.firstBatch.0.code": Eq(DOCUMENT_VALIDATION_FAILURE_ERROR), +} + +BULKWRITE_BYPASS_FALSY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "bypass_falsy_false", + target_collection=_validated_collection(), + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "bypassDocumentValidation": False, + }, + expected=_ENFORCE, + msg="bulkWrite bypassDocumentValidation=false (falsy) should enforce the validator", + ), + CommandTestCase( + "bypass_falsy_int32_0", + target_collection=_validated_collection(), + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "bypassDocumentValidation": INT32_ZERO, + }, + expected=_ENFORCE, + msg="bulkWrite bypassDocumentValidation=int32 0 (falsy) should enforce the validator", + ), + CommandTestCase( + "bypass_falsy_double_0", + target_collection=_validated_collection(), + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "bypassDocumentValidation": DOUBLE_ZERO, + }, + expected=_ENFORCE, + msg="bulkWrite bypassDocumentValidation=double 0.0 (falsy) should enforce the validator", + ), + CommandTestCase( + "bypass_falsy_double_neg_zero", + target_collection=_validated_collection(), + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "bypassDocumentValidation": DOUBLE_NEGATIVE_ZERO, + }, + expected=_ENFORCE, + msg="bulkWrite bypassDocumentValidation=double -0.0 (falsy) should enforce the validator", + ), + CommandTestCase( + "bypass_falsy_decimal128_0", + target_collection=_validated_collection(), + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "bypassDocumentValidation": DECIMAL128_ZERO, + }, + expected=_ENFORCE, + msg="bulkWrite bypassDocumentValidation=Decimal128 0 (falsy) should enforce the validator", + ), + CommandTestCase( + "bypass_falsy_decimal128_neg_zero", + target_collection=_validated_collection(), + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "bypassDocumentValidation": DECIMAL128_NEGATIVE_ZERO, + }, + expected=_ENFORCE, + msg="bulkWrite bypassDocumentValidation=Decimal128 -0 (falsy) should enforce the validator", + ), +] + +_OPS = [{"insert": 0, "document": {"_id": 1}}] + +BULKWRITE_BYPASS_REJECTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "bypass_reject_string", + command={"bulkWrite": 1, "ops": _OPS, "bypassDocumentValidation": "hello"}, + error_code=TYPE_MISMATCH_ERROR, + msg="bulkWrite should reject string for bypassDocumentValidation with TypeMismatch", + ), + CommandTestCase( + "bypass_reject_array", + command={"bulkWrite": 1, "ops": _OPS, "bypassDocumentValidation": [1, 2]}, + error_code=TYPE_MISMATCH_ERROR, + msg="bulkWrite should reject array for bypassDocumentValidation with TypeMismatch", + ), + CommandTestCase( + "bypass_reject_document", + command={"bulkWrite": 1, "ops": _OPS, "bypassDocumentValidation": {"a": 1}}, + error_code=TYPE_MISMATCH_ERROR, + msg="bulkWrite should reject document for bypassDocumentValidation with TypeMismatch", + ), + CommandTestCase( + "bypass_reject_objectid", + command={"bulkWrite": 1, "ops": _OPS, "bypassDocumentValidation": ObjectId()}, + error_code=TYPE_MISMATCH_ERROR, + msg="bulkWrite should reject objectid for bypassDocumentValidation with TypeMismatch", + ), + CommandTestCase( + "bypass_reject_datetime", + command={ + "bulkWrite": 1, + "ops": _OPS, + "bypassDocumentValidation": datetime(2024, 1, 1, tzinfo=timezone.utc), + }, + error_code=TYPE_MISMATCH_ERROR, + msg="bulkWrite should reject datetime for bypassDocumentValidation with TypeMismatch", + ), + CommandTestCase( + "bypass_reject_timestamp", + command={"bulkWrite": 1, "ops": _OPS, "bypassDocumentValidation": Timestamp(1, 1)}, + error_code=TYPE_MISMATCH_ERROR, + msg="bulkWrite should reject timestamp for bypassDocumentValidation with TypeMismatch", + ), + CommandTestCase( + "bypass_reject_regex", + command={"bulkWrite": 1, "ops": _OPS, "bypassDocumentValidation": Regex(".*")}, + error_code=TYPE_MISMATCH_ERROR, + msg="bulkWrite should reject regex for bypassDocumentValidation with TypeMismatch", + ), + CommandTestCase( + "bypass_reject_binary", + command={"bulkWrite": 1, "ops": _OPS, "bypassDocumentValidation": Binary(b"hello")}, + error_code=TYPE_MISMATCH_ERROR, + msg="bulkWrite should reject binary for bypassDocumentValidation with TypeMismatch", + ), + CommandTestCase( + "bypass_reject_code", + command={"bulkWrite": 1, "ops": _OPS, "bypassDocumentValidation": Code("function(){}")}, + error_code=TYPE_MISMATCH_ERROR, + msg="bulkWrite should reject code for bypassDocumentValidation with TypeMismatch", + ), + CommandTestCase( + "bypass_reject_code_with_scope", + command={ + "bulkWrite": 1, + "ops": _OPS, + "bypassDocumentValidation": Code("function(){}", {"x": 1}), + }, + error_code=TYPE_MISMATCH_ERROR, + msg="bulkWrite should reject code_with_scope for bypassDocumentValidation", + ), + CommandTestCase( + "bypass_reject_minkey", + command={"bulkWrite": 1, "ops": _OPS, "bypassDocumentValidation": MinKey()}, + error_code=TYPE_MISMATCH_ERROR, + msg="bulkWrite should reject minkey for bypassDocumentValidation with TypeMismatch", + ), + CommandTestCase( + "bypass_reject_maxkey", + command={"bulkWrite": 1, "ops": _OPS, "bypassDocumentValidation": MaxKey()}, + error_code=TYPE_MISMATCH_ERROR, + msg="bulkWrite should reject maxkey for bypassDocumentValidation with TypeMismatch", + ), +] + +BULKWRITE_BYPASS_VALIDATION_TESTS = ( + BULKWRITE_BYPASS_TRUTHY_TESTS + BULKWRITE_BYPASS_FALSY_TESTS + BULKWRITE_BYPASS_REJECTION_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(BULKWRITE_BYPASS_VALIDATION_TESTS)) +def test_bulkWrite_bypass_validation(database_client, collection, test): + """Test bulkWrite bypassDocumentValidation value coercion and type rejection.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + command = test.build_command(ctx) + if "nsInfo" not in command: + command = {**command, "nsInfo": [{"ns": ctx.namespace}]} + result = execute_admin_command(collection, command) + 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/query_and_write/commands/bulkWrite/test_bulkWrite_core_delete.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_core_delete.py new file mode 100644 index 000000000..0610402ab --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_core_delete.py @@ -0,0 +1,106 @@ +"""Tests for bulkWrite core delete operations.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertSuccess, assertSuccessPartial +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.parametrize import pytest_params + +BULKWRITE_DELETE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "single_delete", + docs=[{"_id": 1, "x": 1}], + command={"bulkWrite": 1, "ops": [{"delete": 0, "filter": {"_id": 1}}]}, + expected={"ok": 1.0, "nDeleted": 1}, + msg="bulkWrite should perform a single delete", + ), + CommandTestCase( + "delete_no_match", + docs=[{"_id": 1, "x": 1}], + command={"bulkWrite": 1, "ops": [{"delete": 0, "filter": {"_id": 999}}]}, + expected={"ok": 1.0, "nDeleted": 0}, + msg="bulkWrite delete with a non-matching filter should delete nothing", + ), + CommandTestCase( + "delete_multi_true", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 1}, {"_id": 3, "x": 2}], + command={"bulkWrite": 1, "ops": [{"delete": 0, "filter": {"x": 1}, "multi": True}]}, + expected={"ok": 1.0, "nDeleted": 2}, + msg="bulkWrite delete with multi:true should delete all matching documents", + ), + CommandTestCase( + "delete_multi_false", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 1}], + command={"bulkWrite": 1, "ops": [{"delete": 0, "filter": {"x": 1}, "multi": False}]}, + expected={"ok": 1.0, "nDeleted": 1}, + msg="bulkWrite delete with multi:false should delete only the first match", + ), + CommandTestCase( + "delete_nonexistent_collection", + command={"bulkWrite": 1, "ops": [{"delete": 0, "filter": {"x": 1}}]}, + expected={"ok": 1.0, "nDeleted": 0}, + msg="bulkWrite delete on a non-existent collection should delete nothing", + ), + CommandTestCase( + "delete_empty_collection", + docs=[], + command={"bulkWrite": 1, "ops": [{"delete": 0, "filter": {"x": 1}}]}, + expected={"ok": 1.0, "nDeleted": 0}, + msg="bulkWrite delete on an empty collection should delete nothing", + ), + CommandTestCase( + "insert_then_delete", + command={ + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 1, "x": 1}}, + {"delete": 0, "filter": {"_id": 1}}, + ], + }, + expected={"ok": 1.0, "nInserted": 1, "nDeleted": 1}, + msg="bulkWrite should insert then delete the same document", + ), + CommandTestCase( + "delete_without_multi_deletes_one", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 1}, {"_id": 3, "x": 1}], + command={"bulkWrite": 1, "ops": [{"delete": 0, "filter": {"x": 1}}]}, + expected={"ok": 1.0, "nDeleted": 1}, + msg="bulkWrite delete without the multi flag should delete only one document", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(BULKWRITE_DELETE_TESTS)) +def test_bulkWrite_core_delete(database_client, collection, test): + """Test bulkWrite core delete operations.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + command = test.build_command(ctx) + if "nsInfo" not in command: + command = {**command, "nsInfo": [{"ns": ctx.namespace}]} + result = execute_admin_command(collection, command) + assertSuccessPartial(result, test.build_expected(ctx), msg=test.msg) + + +def test_bulkWrite_delete_namespace_isolation(collection): + """Test delete targeting one namespace does not affect documents in a different namespace.""" + sibling = collection.database[f"{collection.name}_b"] + sibling.drop() + collection.insert_one({"_id": 1, "x": 1}) + sibling.insert_one({"_id": 1, "x": 1}) + ns_a = f"{collection.database.name}.{collection.name}" + execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": [{"delete": 0, "filter": {"_id": 1}}], + "nsInfo": [{"ns": ns_a}], + }, + ) + other_ns = execute_command(sibling, {"find": sibling.name, "filter": {}}) + assertSuccess(other_ns, [{"_id": 1, "x": 1}]) + sibling.drop() diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_core_insert.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_core_insert.py new file mode 100644 index 000000000..7cb6d9ec2 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_core_insert.py @@ -0,0 +1,313 @@ +"""Tests for bulkWrite core insert operations, data type coverage, and document edge cases.""" + +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.target_collection import CappedCollection +from documentdb_tests.framework.test_constants import BSON_TYPE_SAMPLES, BsonType + +BULKWRITE_INSERT_TYPE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "insert_double", + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1, "v": BSON_TYPE_SAMPLES[BsonType.DOUBLE]}}], + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a document with a double field value", + ), + CommandTestCase( + "insert_string", + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1, "v": BSON_TYPE_SAMPLES[BsonType.STRING]}}], + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a document with a string field value", + ), + CommandTestCase( + "insert_object", + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1, "v": BSON_TYPE_SAMPLES[BsonType.OBJECT]}}], + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a document with a object field value", + ), + CommandTestCase( + "insert_array", + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1, "v": BSON_TYPE_SAMPLES[BsonType.ARRAY]}}], + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a document with a array field value", + ), + CommandTestCase( + "insert_bindata", + command={ + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 1, "v": BSON_TYPE_SAMPLES[BsonType.BIN_DATA]}} + ], + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a document with a bindata field value", + ), + CommandTestCase( + "insert_objectid", + command={ + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 1, "v": BSON_TYPE_SAMPLES[BsonType.OBJECT_ID]}} + ], + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a document with a objectid field value", + ), + CommandTestCase( + "insert_boolean", + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1, "v": BSON_TYPE_SAMPLES[BsonType.BOOL]}}], + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a document with a boolean field value", + ), + CommandTestCase( + "insert_date", + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1, "v": BSON_TYPE_SAMPLES[BsonType.DATE]}}], + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a document with a date field value", + ), + CommandTestCase( + "insert_null", + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1, "v": BSON_TYPE_SAMPLES[BsonType.NULL]}}], + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a document with a null field value", + ), + CommandTestCase( + "insert_regex", + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1, "v": BSON_TYPE_SAMPLES[BsonType.REGEX]}}], + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a document with a regex field value", + ), + CommandTestCase( + "insert_int", + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1, "v": BSON_TYPE_SAMPLES[BsonType.INT]}}], + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a document with a int field value", + ), + CommandTestCase( + "insert_long", + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1, "v": BSON_TYPE_SAMPLES[BsonType.LONG]}}], + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a document with a long field value", + ), + CommandTestCase( + "insert_timestamp", + command={ + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 1, "v": BSON_TYPE_SAMPLES[BsonType.TIMESTAMP]}} + ], + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a document with a timestamp field value", + ), + CommandTestCase( + "insert_decimal128", + command={ + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 1, "v": BSON_TYPE_SAMPLES[BsonType.DECIMAL]}} + ], + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a document with a decimal128 field value", + ), + CommandTestCase( + "insert_minkey", + command={ + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 1, "v": BSON_TYPE_SAMPLES[BsonType.MIN_KEY]}} + ], + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a document with a minkey field value", + ), + CommandTestCase( + "insert_maxkey", + command={ + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 1, "v": BSON_TYPE_SAMPLES[BsonType.MAX_KEY]}} + ], + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a document with a maxkey field value", + ), + CommandTestCase( + "insert_javascript", + command={ + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 1, "v": BSON_TYPE_SAMPLES[BsonType.JAVASCRIPT]}} + ], + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a document with a javascript field value", + ), +] + +BULKWRITE_INSERT_CORE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "single_insert", + command={"bulkWrite": 1, "ops": [{"insert": 0, "document": {"_id": 1, "a": 1}}]}, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should perform a single insert", + ), + CommandTestCase( + "multiple_inserts", + command={ + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 1, "a": 1}}, + {"insert": 0, "document": {"_id": 2, "a": 2}}, + {"insert": 0, "document": {"_id": 3, "a": 3}}, + ], + }, + expected={"ok": 1.0, "nInserted": 3}, + msg="bulkWrite should perform multiple inserts in one command", + ), + CommandTestCase( + "insert_with_specified_id", + command={"bulkWrite": 1, "ops": [{"insert": 0, "document": {"_id": 100, "x": "hello"}}]}, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a document with a specified _id", + ), + CommandTestCase( + "insert_auto_generated_id", + command={"bulkWrite": 1, "ops": [{"insert": 0, "document": {"x": "no_id"}}]}, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a document with an auto-generated _id", + ), + CommandTestCase( + "insert_into_nonexistent_collection", + command={"bulkWrite": 1, "ops": [{"insert": 0, "document": {"_id": 1, "x": 1}}]}, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite insert should implicitly create a non-existent collection", + ), + CommandTestCase( + "insert_empty_document", + command={"bulkWrite": 1, "ops": [{"insert": 0, "document": {}}]}, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert an empty document with an auto-generated _id", + ), + CommandTestCase( + "insert_deeply_nested", + command={ + "bulkWrite": 1, + "ops": [ + { + "insert": 0, + "document": { + "_id": 1, + "level0": { + "level1": { + "level2": { + "level3": { + "level4": { + "level5": { + "level6": { + "level7": { + "level8": { + "level9": { + "level10": { + "level11": {"value": "deep"} + } + } + } + } + } + } + } + } + } + } + }, + }, + } + ], + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a deeply nested document", + ), + CommandTestCase( + "insert_long_field_names", + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1, "a" * 200: "value"}}], + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a document with very long field names", + ), + CommandTestCase( + "insert_dollar_prefixed_fields", + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1, "$dollar": "value"}}], + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a document with dollar-prefixed field names", + ), + CommandTestCase( + "insert_dot_notation_fields", + command={"bulkWrite": 1, "ops": [{"insert": 0, "document": {"_id": 1, "a.b": "value"}}]}, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a document with dot-notation field names", + ), + CommandTestCase( + "insert_into_capped_collection", + target_collection=CappedCollection(size=1048576), + command={"bulkWrite": 1, "ops": [{"insert": 0, "document": {"_id": 1, "x": 1}}]}, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert into a capped collection", + ), +] + +BULKWRITE_INSERT_TESTS = BULKWRITE_INSERT_TYPE_TESTS + BULKWRITE_INSERT_CORE_TESTS + + +@pytest.mark.parametrize("test", pytest_params(BULKWRITE_INSERT_TESTS)) +def test_bulkWrite_core_insert(database_client, collection, test): + """Test bulkWrite core insert operations, data type coverage, and document edge cases.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + command = test.build_command(ctx) + if "nsInfo" not in command: + command = {**command, "nsInfo": [{"ns": ctx.namespace}]} + result = execute_admin_command(collection, command) + assertSuccessPartial(result, test.build_expected(ctx), msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_core_update.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_core_update.py new file mode 100644 index 000000000..1c13fcf6f --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_core_update.py @@ -0,0 +1,281 @@ +"""Tests for bulkWrite core update operations, filter edge cases, and multi-operation handling.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import ( + assertResult, + assertSuccess, + 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 + +BULKWRITE_UPDATE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "single_update", + docs=[{"_id": 1, "x": 10}], + command={ + "bulkWrite": 1, + "ops": [{"update": 0, "filter": {"_id": 1}, "updateMods": {"$set": {"x": 20}}}], + }, + expected={"ok": 1.0, "nMatched": 1, "nModified": 1}, + msg="bulkWrite should perform a single update", + ), + CommandTestCase( + "update_set_inc_unset", + docs=[{"_id": 1, "x": 10, "y": 5, "z": 1}], + command={ + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"_id": 1}, + "updateMods": {"$set": {"x": 20}, "$inc": {"y": 1}, "$unset": {"z": ""}}, + } + ], + }, + expected={"ok": 1.0, "nMatched": 1, "nModified": 1}, + msg="bulkWrite update should apply $set, $inc, and $unset operators", + ), + CommandTestCase( + "update_pipeline", + docs=[{"_id": 1, "x": 10, "y": 5}], + command={ + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"_id": 1}, + "updateMods": [{"$set": {"x": 99}}, {"$unset": "y"}], + } + ], + }, + expected={"ok": 1.0, "nMatched": 1, "nModified": 1}, + msg="bulkWrite update should accept an aggregation pipeline", + ), + CommandTestCase( + "update_no_match", + docs=[{"_id": 1, "x": 10}], + command={ + "bulkWrite": 1, + "ops": [{"update": 0, "filter": {"_id": 999}, "updateMods": {"$set": {"x": 20}}}], + }, + expected={"ok": 1.0, "nMatched": 0, "nModified": 0}, + msg="bulkWrite update with a non-matching filter should match nothing", + ), + CommandTestCase( + "update_multi_true", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 1}, {"_id": 3, "x": 2}], + command={ + "bulkWrite": 1, + "ops": [ + {"update": 0, "filter": {"x": 1}, "updateMods": {"$set": {"x": 99}}, "multi": True} + ], + }, + expected={"ok": 1.0, "nMatched": 2, "nModified": 2}, + msg="bulkWrite update with multi:true should update all matching documents", + ), + CommandTestCase( + "update_multi_false", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 1}], + command={ + "bulkWrite": 1, + "ops": [ + {"update": 0, "filter": {"x": 1}, "updateMods": {"$set": {"x": 99}}, "multi": False} + ], + }, + expected={"ok": 1.0, "nMatched": 1, "nModified": 1}, + msg="bulkWrite update with multi:false should update only the first match", + ), + CommandTestCase( + "upsert_true_no_match", + command={ + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"_id": 100}, + "updateMods": {"$set": {"x": 1}}, + "upsert": True, + } + ], + }, + expected={"ok": 1.0, "nUpserted": 1}, + msg="bulkWrite update with upsert:true and no match should insert a document", + ), + CommandTestCase( + "update_nonexistent_collection", + command={ + "bulkWrite": 1, + "ops": [{"update": 0, "filter": {"x": 1}, "updateMods": {"$set": {"x": 2}}}], + }, + expected={"ok": 1.0, "nMatched": 0, "nModified": 0}, + msg="bulkWrite update on a non-existent collection should match nothing", + ), + CommandTestCase( + "update_empty_collection", + docs=[], + command={ + "bulkWrite": 1, + "ops": [{"update": 0, "filter": {"x": 1}, "updateMods": {"$set": {"x": 2}}}], + }, + expected={"ok": 1.0, "nMatched": 0, "nModified": 0}, + msg="bulkWrite update on an empty collection should match nothing", + ), + CommandTestCase( + "update_empty_filter_multi", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 2}], + command={ + "bulkWrite": 1, + "ops": [{"update": 0, "filter": {}, "updateMods": {"$set": {"y": 1}}, "multi": True}], + }, + expected={"ok": 1.0, "nMatched": 2, "nModified": 2}, + msg="bulkWrite update with an empty filter and multi:true should match all documents", + ), + CommandTestCase( + "update_and_or_filter", + docs=[{"_id": 1, "x": 1, "y": 1}, {"_id": 2, "x": 1, "y": 2}], + command={ + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"$and": [{"x": 1}, {"$or": [{"y": 1}]}]}, + "updateMods": {"$set": {"z": 1}}, + } + ], + }, + expected={"ok": 1.0, "nMatched": 1, "nModified": 1}, + msg="bulkWrite update should support $and/$or operators in the filter", + ), + CommandTestCase( + "update_regex_filter", + docs=[{"_id": 1, "name": "Alice"}, {"_id": 2, "name": "Bob"}], + command={ + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"name": {"$regex": "^A"}}, + "updateMods": {"$set": {"matched": True}}, + } + ], + }, + expected={"ok": 1.0, "nMatched": 1, "nModified": 1}, + msg="bulkWrite update should support a regex filter", + ), + CommandTestCase( + "update_expr_filter", + docs=[{"_id": 1, "x": 5}, {"_id": 2, "x": 15}], + command={ + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"$expr": {"$gt": ["$x", 10]}}, + "updateMods": {"$set": {"big": True}}, + } + ], + }, + expected={"ok": 1.0, "nMatched": 1, "nModified": 1}, + msg="bulkWrite update should support $expr in the filter", + ), + CommandTestCase( + "large_batch_updates", + docs=[{"_id": i, "x": i} for i in range(100)], + command={ + "bulkWrite": 1, + "ops": [ + {"update": 0, "filter": {"_id": i}, "updateMods": {"$set": {"x": i + 1}}} + for i in range(100) + ], + }, + expected={"ok": 1.0, "nMatched": 100, "nModified": 100}, + msg="bulkWrite should apply a large batch of update operations", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(BULKWRITE_UPDATE_TESTS)) +def test_bulkWrite_core_update(database_client, collection, test): + """Test bulkWrite core update operations, filter edge cases, and multi-operation handling.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + command = test.build_command(ctx) + if "nsInfo" not in command: + command = {**command, "nsInfo": [{"ns": ctx.namespace}]} + result = execute_admin_command(collection, command) + assertSuccessPartial(result, test.build_expected(ctx), msg=test.msg) + + +def test_bulkWrite_sequential_ops_accumulate_on_same_document(collection): + """Test multiple ops on the same document in one bulkWrite apply sequentially (x: 1->2->12).""" + collection.insert_one({"_id": 1, "x": 1}) + ns = f"{collection.database.name}.{collection.name}" + execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": [ + {"update": 0, "filter": {"_id": 1}, "updateMods": {"$set": {"x": 2}}}, + {"update": 0, "filter": {"_id": 1}, "updateMods": {"$inc": {"x": 10}}}, + ], + "nsInfo": [{"ns": ns}], + }, + ) + result = execute_command(collection, {"find": collection.name, "filter": {"_id": 1}}) + assertSuccess(result, [{"_id": 1, "x": 12}]) + + +def test_bulkWrite_replacement_style_update_replaces_document(collection): + """Test a replacement-style updateMods (no $ operators) replaces the whole document body.""" + collection.insert_one({"_id": 1, "x": 10, "y": 20, "z": 30}) + ns = f"{collection.database.name}.{collection.name}" + execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": [{"update": 0, "filter": {"_id": 1}, "updateMods": {"a": 1, "b": 2}}], + "nsInfo": [{"ns": ns}], + }, + ) + assertSuccess( + execute_command(collection, {"find": collection.name, "filter": {"_id": 1}}), + [{"_id": 1, "a": 1, "b": 2}], + msg="bulkWrite replacement-style update should replace the body, removing non-_id fields", + ) + + +def test_bulkWrite_upsert_id_in_response(collection): + """Test an upsert carries the upserted _id in cursor.firstBatch[].upserted._id.""" + ns = f"{collection.database.name}.{collection.name}" + result = execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"_id": 100}, + "updateMods": {"$set": {"x": 1}}, + "upsert": True, + } + ], + "nsInfo": [{"ns": ns}], + }, + ) + assertResult( + result, + expected={ + "ok": Eq(1.0), + "nUpserted": Eq(1), + "cursor.firstBatch.0.upserted._id": Eq(100), + }, + raw_res=True, + msg="bulkWrite upsert should carry the upserted _id in cursor.firstBatch[].upserted._id", + ) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_errors.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_errors.py new file mode 100644 index 000000000..c27de3122 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_errors.py @@ -0,0 +1,626 @@ +"""Tests for bulkWrite error and rejection cases.""" + +import uuid + +import pytest +from bson.binary import Binary + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, + IndexModel, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + BSON_OBJECT_TOO_LARGE_ERROR, + COLLECTION_UUID_MISMATCH_ERROR, + COMMAND_NOT_FOUND_ERROR, + COMMAND_NOT_SUPPORTED_ON_VIEW_ERROR, + DOCUMENT_VALIDATION_FAILURE_ERROR, + DUPLICATE_KEY_ERROR, + FAILED_TO_PARSE_ERROR, + IMMUTABLE_FIELD_ERROR, + INVALID_BSON_ID_ERROR, + INVALID_LENGTH_ERROR, + INVALID_NAMESPACE_ERROR, + MISSING_FIELD_ERROR, + UNRECOGNIZED_COMMAND_FIELD_ERROR, + UPDATE_C_FIELD_REQUIRES_PIPELINE_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.target_collection import CustomCollection, ViewCollection + +BULKWRITE_REJECTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "ops_empty_array", + command={"bulkWrite": 1, "ops": []}, + error_code=INVALID_LENGTH_ERROR, + msg="bulkWrite with an empty ops array should fail with InvalidLength", + ), + CommandTestCase( + "ops_field_missing", + command={"bulkWrite": 1}, + error_code=MISSING_FIELD_ERROR, + msg="bulkWrite without an ops field should fail with MissingField", + ), + CommandTestCase( + "ops_insert_missing_document", + command={"bulkWrite": 1, "ops": [{"insert": 0}]}, + error_code=MISSING_FIELD_ERROR, + msg="bulkWrite insert op missing document should fail with MissingField", + ), + CommandTestCase( + "ops_update_missing_filter", + command={"bulkWrite": 1, "ops": [{"update": 0, "updateMods": {"$set": {"x": 1}}}]}, + error_code=MISSING_FIELD_ERROR, + msg="bulkWrite update op missing filter should fail with MissingField", + ), + CommandTestCase( + "ops_delete_missing_filter", + command={"bulkWrite": 1, "ops": [{"delete": 0}]}, + error_code=MISSING_FIELD_ERROR, + msg="bulkWrite delete op missing filter should fail with MissingField", + ), + CommandTestCase( + "ops_invalid_namespace_index", + command={"bulkWrite": 1, "ops": [{"insert": 5, "document": {"_id": 1}}]}, + error_code=BAD_VALUE_ERROR, + msg="bulkWrite op referencing a non-existent nsInfo index should fail with BadValue", + ), + CommandTestCase( + "ops_negative_namespace_index", + command={"bulkWrite": 1, "ops": [{"insert": -1, "document": {"_id": 1}}]}, + error_code=BAD_VALUE_ERROR, + msg="bulkWrite op with a negative namespace index should fail with BadValue", + ), + CommandTestCase( + "nsInfo_empty_array", + command={"bulkWrite": 1, "ops": [{"insert": 0, "document": {"_id": 1}}], "nsInfo": []}, + error_code=BAD_VALUE_ERROR, + msg="bulkWrite with an empty nsInfo array should fail with BadValue", + ), + CommandTestCase( + "nsInfo_invalid_ns_format", + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "nsInfo": [{"ns": "no_dot"}], + }, + error_code=INVALID_NAMESPACE_ERROR, + msg="bulkWrite with an invalid namespace format should fail with InvalidNamespace", + ), + CommandTestCase( + "unrecognized_top_level_field", + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "unknownField": True, + }, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="bulkWrite with an unrecognized top-level field should fail", + ), + CommandTestCase( + "unrecognized_op_discriminator", + command={ + "bulkWrite": 1, + "ops": [{"replace": 0, "document": {"_id": 1}}], + }, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="bulkWrite op with an unrecognized discriminator should fail at parse time", + ), +] + +BULKWRITE_OPERATION_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "invalid_filter_expression", + command={ + "bulkWrite": 1, + "ops": [{"update": 0, "filter": {"$badOp": 1}, "updateMods": {"$set": {"x": 1}}}], + }, + expected={ + "ok": Eq(1.0), + "nErrors": Eq(1), + "nModified": Eq(0), + "cursor.firstBatch.0.ok": Eq(0.0), + "cursor.firstBatch.0.idx": Eq(0), + "cursor.firstBatch.0.code": Eq(BAD_VALUE_ERROR), + }, + msg="bulkWrite with an invalid query operator should report nErrors:1", + ), + CommandTestCase( + "invalid_update_expression", + docs=[{"_id": 1, "x": 1}], + command={ + "bulkWrite": 1, + "ops": [{"update": 0, "filter": {"_id": 1}, "updateMods": {"$badOp": {"x": 1}}}], + }, + expected={ + "ok": Eq(1.0), + "nErrors": Eq(1), + "nModified": Eq(0), + "cursor.firstBatch.0.ok": Eq(0.0), + "cursor.firstBatch.0.idx": Eq(0), + "cursor.firstBatch.0.code": Eq(FAILED_TO_PARSE_ERROR), + }, + msg="bulkWrite with an invalid update operator should report nErrors:1", + ), + CommandTestCase( + "duplicate_id_insert", + docs=[{"_id": 1}], + command={"bulkWrite": 1, "ops": [{"insert": 0, "document": {"_id": 1}}]}, + expected={"ok": Eq(1.0), "nErrors": Eq(1), "nInserted": Eq(0)}, + msg="bulkWrite duplicate _id insert should report nErrors:1", + ), + CommandTestCase( + "ordered_true_stops_at_dupkey", + docs=[{"_id": 1}], + command={ + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 1}}, + {"insert": 0, "document": {"_id": 2}}, + {"insert": 0, "document": {"_id": 3}}, + ], + "ordered": True, + }, + expected={"ok": Eq(1.0), "nErrors": Eq(1), "nInserted": Eq(0)}, + msg="bulkWrite ordered:true should stop at the duplicate key", + ), + CommandTestCase( + "ordered_false_continues_after_dupkey", + docs=[{"_id": 1}], + command={ + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 1}}, + {"insert": 0, "document": {"_id": 2}}, + {"insert": 0, "document": {"_id": 3}}, + ], + "ordered": False, + }, + expected={"ok": Eq(1.0), "nErrors": Eq(1), "nInserted": Eq(2)}, + msg="bulkWrite ordered:false should continue after the duplicate key", + ), + CommandTestCase( + "insert_unique_index_violation", + indexes=[IndexModel([("x", 1)], unique=True)], + docs=[{"_id": 1, "x": 1}], + command={"bulkWrite": 1, "ops": [{"insert": 0, "document": {"_id": 2, "x": 1}}]}, + expected={ + "ok": Eq(1.0), + "nErrors": Eq(1), + "nInserted": Eq(0), + "cursor.firstBatch.0.ok": Eq(0.0), + "cursor.firstBatch.0.idx": Eq(0), + "cursor.firstBatch.0.code": Eq(DUPLICATE_KEY_ERROR), + }, + msg="bulkWrite insert violating a unique index should report nErrors:1", + ), + CommandTestCase( + "update_causes_unique_index_violation", + indexes=[IndexModel([("x", 1)], unique=True)], + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 2}], + command={ + "bulkWrite": 1, + "ops": [{"update": 0, "filter": {"_id": 2}, "updateMods": {"$set": {"x": 1}}}], + }, + expected={ + "ok": Eq(1.0), + "nErrors": Eq(1), + "nModified": Eq(0), + "cursor.firstBatch.0.ok": Eq(0.0), + "cursor.firstBatch.0.idx": Eq(0), + "cursor.firstBatch.0.code": Eq(DUPLICATE_KEY_ERROR), + }, + msg="bulkWrite update causing a unique index violation should report nErrors:1", + ), + CommandTestCase( + "update_immutable_field", + docs=[{"_id": 1, "x": 10}], + command={ + "bulkWrite": 1, + "ops": [{"update": 0, "filter": {"_id": 1}, "updateMods": {"$set": {"_id": 2}}}], + }, + expected={ + "ok": Eq(1.0), + "nErrors": Eq(1), + "nModified": Eq(0), + "cursor.firstBatch.0.ok": Eq(0.0), + "cursor.firstBatch.0.idx": Eq(0), + "cursor.firstBatch.0.code": Eq(IMMUTABLE_FIELD_ERROR), + }, + msg="bulkWrite update of the immutable _id field should report nErrors:1", + ), + CommandTestCase( + "update_on_view", + target_collection=ViewCollection(), + docs=[{"_id": 1, "x": 1}], + command={ + "bulkWrite": 1, + "ops": [{"update": 0, "filter": {}, "updateMods": {"$set": {"x": 2}}}], + }, + expected={ + "ok": Eq(1.0), + "nErrors": Eq(1), + "nModified": Eq(0), + "cursor.firstBatch.0.ok": Eq(0.0), + "cursor.firstBatch.0.idx": Eq(0), + "cursor.firstBatch.0.code": Eq(COMMAND_NOT_SUPPORTED_ON_VIEW_ERROR), + }, + msg="bulkWrite update on a view should report nErrors:1", + ), + CommandTestCase( + "delete_on_view", + target_collection=ViewCollection(), + docs=[{"_id": 1, "x": 1}], + command={ + "bulkWrite": 1, + "ops": [{"delete": 0, "filter": {}}], + }, + expected={ + "ok": Eq(1.0), + "nErrors": Eq(1), + "nDeleted": Eq(0), + "cursor.firstBatch.0.ok": Eq(0.0), + "cursor.firstBatch.0.idx": Eq(0), + "cursor.firstBatch.0.code": Eq(COMMAND_NOT_SUPPORTED_ON_VIEW_ERROR), + }, + msg="bulkWrite delete on a view should report nErrors:1", + ), + CommandTestCase( + "schema_validation_rejects_insert", + target_collection=CustomCollection( + options={"validator": {"$jsonSchema": {"required": ["name"]}}} + ), + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "bypassDocumentValidation": False, + }, + expected={ + "ok": Eq(1.0), + "nErrors": Eq(1), + "nInserted": Eq(0), + "cursor.firstBatch.0.ok": Eq(0.0), + "cursor.firstBatch.0.idx": Eq(0), + "cursor.firstBatch.0.code": Eq(DOCUMENT_VALIDATION_FAILURE_ERROR), + }, + msg="bulkWrite bypassDocumentValidation:false should reject a validator-violating insert", + ), + CommandTestCase( + "arrayFilters_invalid_filter", + docs=[{"_id": 1, "items": [{"x": 1}]}], + command={ + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"_id": 1}, + "updateMods": {"$set": {"items.$[elem].x": 99}}, + "arrayFilters": [{"elem.x": {"$badOp": 1}}], + } + ], + }, + expected={ + "ok": Eq(1.0), + "nErrors": Eq(1), + "nModified": Eq(0), + "cursor.firstBatch.0.ok": Eq(0.0), + "cursor.firstBatch.0.idx": Eq(0), + "cursor.firstBatch.0.code": Eq(BAD_VALUE_ERROR), + }, + msg="bulkWrite arrayFilters with an invalid operator should report nErrors:1", + ), + CommandTestCase( + "update_hint_nonexistent_index", + docs=[{"_id": 1, "x": 10}], + command={ + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"x": 10}, + "updateMods": {"$set": {"x": 20}}, + "hint": "nonexistent_index", + } + ], + }, + expected={ + "ok": Eq(1.0), + "nErrors": Eq(1), + "nModified": Eq(0), + "cursor.firstBatch.0.ok": Eq(0.0), + "cursor.firstBatch.0.idx": Eq(0), + "cursor.firstBatch.0.code": Eq(BAD_VALUE_ERROR), + }, + msg="bulkWrite update with a hint on a non-existent index should report nErrors:1", + ), + CommandTestCase( + "delete_hint_nonexistent_index", + docs=[{"_id": 1, "x": 10}], + command={ + "bulkWrite": 1, + "ops": [{"delete": 0, "filter": {"x": 10}, "hint": "nonexistent_index"}], + }, + expected={ + "ok": Eq(1.0), + "nErrors": Eq(1), + "nDeleted": Eq(0), + "cursor.firstBatch.0.ok": Eq(0.0), + "cursor.firstBatch.0.idx": Eq(0), + "cursor.firstBatch.0.code": Eq(BAD_VALUE_ERROR), + }, + msg="bulkWrite delete with a hint on a non-existent index should report nErrors:1", + ), + CommandTestCase( + "constants_on_non_pipeline_update", + docs=[{"_id": 1, "x": 10}], + command={ + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"_id": 1}, + "updateMods": {"$set": {"x": 20}}, + "constants": {"val": 1}, + } + ], + }, + expected={ + "ok": Eq(1.0), + "nErrors": Eq(1), + "nModified": Eq(0), + "cursor.firstBatch.0.ok": Eq(0.0), + "cursor.firstBatch.0.idx": Eq(0), + "cursor.firstBatch.0.code": Eq(UPDATE_C_FIELD_REQUIRES_PIPELINE_ERROR), + }, + msg="bulkWrite constants on a non-pipeline update should report nErrors:1", + ), + CommandTestCase( + "errorsOnly_true_with_error", + docs=[{"_id": 1}], + command={ + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 1}}, + {"insert": 0, "document": {"_id": 2}}, + ], + "errorsOnly": True, + "ordered": False, + }, + expected={"ok": Eq(1.0), "nErrors": Eq(1)}, + msg="bulkWrite errorsOnly:true with a failing operation should report nErrors:1", + ), + CommandTestCase( + "array_id_surfaces_as_op_level_error", + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": [1, 2]}}], + }, + expected={ + "ok": Eq(1.0), + "nErrors": Eq(1), + "nInserted": Eq(0), + "cursor.firstBatch.0.ok": Eq(0.0), + "cursor.firstBatch.0.idx": Eq(0), + "cursor.firstBatch.0.code": Eq(INVALID_BSON_ID_ERROR), + }, + msg="bulkWrite should surface an array _id as an op-level error (code 53), not reject it", + ), + CommandTestCase( + "collection_uuid_mismatch", + docs=[{"_id": 0}], + command=lambda ctx: { + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "nsInfo": [{"ns": ctx.namespace, "collectionUUID": Binary(uuid.uuid4().bytes, 4)}], + }, + # Unlike renameCollection (top-level), bulkWrite surfaces this mismatch op-level. + expected={ + "ok": Eq(1.0), + "nErrors": Eq(1), + "nInserted": Eq(0), + "cursor.firstBatch.0.ok": Eq(0.0), + "cursor.firstBatch.0.idx": Eq(0), + "cursor.firstBatch.0.code": Eq(COLLECTION_UUID_MISMATCH_ERROR), + }, + msg="bulkWrite should surface a collectionUUID mismatch as an op-level error (code 361)", + ), +] + +BULKWRITE_ERROR_TESTS = BULKWRITE_REJECTION_TESTS + BULKWRITE_OPERATION_ERROR_TESTS + + +@pytest.mark.parametrize("test", pytest_params(BULKWRITE_ERROR_TESTS)) +def test_bulkWrite_errors(database_client, collection, test): + """Test bulkWrite error and rejection cases.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + command = test.build_command(ctx) + if "nsInfo" not in command: + command = {**command, "nsInfo": [{"ns": ctx.namespace}]} + result = execute_admin_command(collection, command) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + + +def test_bulkWrite_failure_in_one_ns_does_not_block_another(collection): + """Test an unordered bulkWrite failure in one namespace does not block writes to another.""" + sibling = collection.database[f"{collection.name}_b"] + sibling.drop() + collection.insert_one({"_id": 1, "x": 1}) + ns = f"{collection.database.name}.{collection.name}" + ns_b = f"{collection.database.name}.{sibling.name}" + result = execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 1, "x": 2}}, # dup key on ns 0 + {"insert": 1, "document": {"_id": 1, "y": 1}}, # succeeds on ns 1 + ], + "nsInfo": [{"ns": ns}, {"ns": ns_b}], + "ordered": False, + }, + ) + assertResult( + result, + expected={"ok": Eq(1.0), "nInserted": Eq(1), "nErrors": Eq(1)}, + raw_res=True, + ) + sibling.drop() + + +def test_bulkWrite_command_name_must_be_first_field(collection): + """Test bulkWrite is rejected with CommandNotFound when bulkWrite is not the first field.""" + ns = f"{collection.database.name}.{collection.name}" + # nsInfo is intentionally placed before bulkWrite so it is read as the command name. + result = execute_admin_command( + collection, + { + "nsInfo": [{"ns": ns}], + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + }, + ) + assertResult(result, error_code=COMMAND_NOT_FOUND_ERROR, raw_res=True) + + +def test_bulkWrite_oversized_document(collection): + """Test an op inserting a document over the 16MB BSON limit reports an op-level error.""" + large_doc = {"_id": 1, "data": "x" * (16 * 1024 * 1024)} + ns = f"{collection.database.name}.{collection.name}" + result = execute_admin_command( + collection, + {"bulkWrite": 1, "ops": [{"insert": 0, "document": large_doc}], "nsInfo": [{"ns": ns}]}, + ) + assertResult( + result, + expected={ + "ok": Eq(1.0), + "nErrors": Eq(1), + "nInserted": Eq(0), + "cursor.firstBatch.0.idx": Eq(0), + "cursor.firstBatch.0.code": Eq(BSON_OBJECT_TOO_LARGE_ERROR), + }, + raw_res=True, + msg="bulkWrite oversized document should report an op-level BSONObjectTooLarge error", + ) + + +def test_bulkWrite_oversized_document_in_batch(collection): + """Test an oversized doc fails op-level while a sibling op in the unordered batch succeeds.""" + large_doc = {"_id": 1, "data": "x" * (16 * 1024 * 1024)} + ns = f"{collection.database.name}.{collection.name}" + result = execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": large_doc}, + {"insert": 0, "document": {"_id": 2, "x": 1}}, + ], + "nsInfo": [{"ns": ns}], + "ordered": False, + }, + ) + assertResult( + result, + expected={ + "ok": Eq(1.0), + "nErrors": Eq(1), + "nInserted": Eq(1), + "cursor.firstBatch.0.idx": Eq(0), + "cursor.firstBatch.0.code": Eq(BSON_OBJECT_TOO_LARGE_ERROR), + }, + raw_res=True, + msg="bulkWrite oversized doc should fail op-level while the sibling op succeeds", + ) + + +def test_bulkWrite_duplicate_key_per_op_cursor_detail(collection): + """Test a dup-key op reports per-op detail (idx/code/keyPattern/keyValue) in firstBatch.""" + collection.insert_one({"_id": 1}) + ns = f"{collection.database.name}.{collection.name}" + result = execute_admin_command( + collection, + {"bulkWrite": 1, "ops": [{"insert": 0, "document": {"_id": 1}}], "nsInfo": [{"ns": ns}]}, + ) + assertResult( + result, + expected={ + "ok": Eq(1.0), + "nErrors": Eq(1), + "nInserted": Eq(0), + "cursor.firstBatch.0.ok": Eq(0.0), + "cursor.firstBatch.0.idx": Eq(0), + "cursor.firstBatch.0.code": Eq(DUPLICATE_KEY_ERROR), + "cursor.firstBatch.0.keyPattern": Eq({"_id": 1}), + "cursor.firstBatch.0.keyValue": Eq({"_id": 1}), + }, + raw_res=True, + msg="bulkWrite dup-key op should report per-op detail (idx/code/keyPattern/keyValue) " + "in firstBatch", + ) + + +def test_bulkWrite_ordered_partial_success_cursor_shape(collection): + """Test ordered:true partial-succeeds, errors at idx 1, and omits the never-run 3rd op.""" + collection.insert_one({"_id": 1}) # pre-existing → second op is a dup + ns = f"{collection.database.name}.{collection.name}" + result = execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 2}}, # good + {"insert": 0, "document": {"_id": 1}}, # dup → error at idx 1 + {"insert": 0, "document": {"_id": 3}}, # never runs (ordered:true) + ], + "nsInfo": [{"ns": ns}], + "ordered": True, + }, + ) + assertResult( + result, + expected={ + "ok": Eq(1.0), + "nInserted": Eq(1), + "nErrors": Eq(1), + "cursor.firstBatch": Len(2), # 3rd op absent — never executed + "cursor.firstBatch.0.ok": Eq(1.0), + "cursor.firstBatch.0.idx": Eq(0), + "cursor.firstBatch.1.ok": Eq(0.0), + "cursor.firstBatch.1.idx": Eq(1), + "cursor.firstBatch.1.code": Eq(DUPLICATE_KEY_ERROR), + }, + raw_res=True, + msg="bulkWrite ordered:true should partial-succeed, error at idx 1, and omit the 3rd op", + ) + + +_MAX_WRITE_BATCH_SIZE = 100_000 + + +def test_bulkWrite_exceeds_max_write_batch_size(collection): + """Test exceeding maxWriteBatchSize (100,000 ops) is rejected with InvalidLength.""" + ns = f"{collection.database.name}.{collection.name}" + ops = [{"insert": 0, "document": {}} for _ in range(_MAX_WRITE_BATCH_SIZE + 1)] + result = execute_admin_command( + collection, + {"bulkWrite": 1, "ops": ops, "nsInfo": [{"ns": ns}]}, + ) + assertResult( + result, + error_code=INVALID_LENGTH_ERROR, + raw_res=True, + msg="bulkWrite exceeding maxWriteBatchSize should fail with InvalidLength", + ) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_let_variables.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_let_variables.py new file mode 100644 index 000000000..11109622a --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_let_variables.py @@ -0,0 +1,228 @@ +"""Tests for bulkWrite let variables, per-statement constants, and constants override behavior.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import ( + assertResult, + assertSuccess, + assertSuccessPartial, +) +from documentdb_tests.framework.error_codes import LET_UNDEFINED_VARIABLE_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 + +BULKWRITE_LET_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "let_variables_in_filter", + docs=[{"_id": 1, "x": 10}, {"_id": 2, "x": 20}], + command={ + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"$expr": {"$eq": ["$x", "$$target"]}}, + "updateMods": {"$set": {"matched": True}}, + } + ], + "let": {"target": 10}, + }, + expected={"ok": 1.0, "nMatched": 1, "nModified": 1}, + msg="bulkWrite should resolve let variables referenced in filters", + ), + CommandTestCase( + "let_empty_document", + docs=[{"_id": 1, "x": 1}], + command={ + "bulkWrite": 1, + "ops": [{"update": 0, "filter": {"_id": 1}, "updateMods": {"$set": {"x": 2}}}], + "let": {}, + }, + expected={"ok": 1.0, "nMatched": 1, "nModified": 1}, + msg="bulkWrite should accept an empty let document", + ), + CommandTestCase( + "let_variable_in_delete_filter", + docs=[{"_id": 1, "x": 10}, {"_id": 2, "x": 20}], + command={ + "bulkWrite": 1, + "ops": [{"delete": 0, "filter": {"$expr": {"$eq": ["$x", "$$delTarget"]}}}], + "let": {"delTarget": 10}, + }, + expected={"ok": 1.0, "nDeleted": 1}, + msg="bulkWrite should resolve let variables referenced in delete filters", + ), + CommandTestCase( + "constants_override_let_in_updateMods", + docs=[{"_id": 1, "x": 10}], + command={ + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"_id": 1}, + "updateMods": [{"$set": {"x": "$$val"}}], + "constants": {"val": 99}, + } + ], + "let": {"val": 10}, + }, + expected={"ok": 1.0, "nMatched": 1, "nModified": 1}, + msg="bulkWrite constants should take precedence over let for the same variable", + ), + CommandTestCase( + "let_variable_name_collision_with_field", + docs=[{"_id": 1, "x": 10, "target": 999}], + command={ + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"$expr": {"$eq": ["$x", "$$target"]}}, + "updateMods": {"$set": {"matched": True}}, + } + ], + "let": {"target": 10}, + }, + expected={"ok": 1.0, "nMatched": 1, "nModified": 1}, + msg="bulkWrite let variable should take precedence over a same-named field in $expr", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(BULKWRITE_LET_TESTS)) +def test_bulkWrite_let_variables(database_client, collection, test): + """Test bulkWrite let variables, per-statement constants, and constants override behavior.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + command = test.build_command(ctx) + if "nsInfo" not in command: + command = {**command, "nsInfo": [{"ns": ctx.namespace}]} + result = execute_admin_command(collection, command) + assertSuccessPartial(result, test.build_expected(ctx), msg=test.msg) + + +def test_bulkWrite_constants_override_let_writes_constants_value(collection): + """Test constants takes precedence over let: the constants value 99, not let 10, is written.""" + collection.insert_one({"_id": 1, "x": 10}) + ns = f"{collection.database.name}.{collection.name}" + execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"_id": 1}, + "updateMods": [{"$set": {"x": "$$val"}}], + "constants": {"val": 99}, + } + ], + "nsInfo": [{"ns": ns}], + "let": {"val": 10}, + }, + ) + result = execute_command(collection, {"find": collection.name, "filter": {"_id": 1}}) + assertSuccess(result, [{"_id": 1, "x": 99}]) + + +def test_bulkWrite_let_variable_resolved_in_pipeline_updateMods(collection): + """Test a let $$var in a pipeline updateMods resolves to its value (read-back x==99).""" + collection.insert_one({"_id": 1, "x": 10}) + ns = f"{collection.database.name}.{collection.name}" + execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": [ + {"update": 0, "filter": {"_id": 1}, "updateMods": [{"$set": {"x": "$$newVal"}}]} + ], + "nsInfo": [{"ns": ns}], + "let": {"newVal": 99}, + }, + ) + assertSuccess( + execute_command(collection, {"find": collection.name, "filter": {"_id": 1}}), + [{"_id": 1, "x": 99}], + msg="bulkWrite should resolve a let variable in a pipeline updateMods to its value", + ) + + +def test_bulkWrite_constants_resolved_in_pipeline_update(collection): + """Test a per-statement constant in a pipeline update resolves to x==42 (read-back).""" + collection.insert_one({"_id": 1, "x": 10}) + ns = f"{collection.database.name}.{collection.name}" + execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"_id": 1}, + "updateMods": [{"$set": {"x": "$$myConst"}}], + "constants": {"myConst": 42}, + } + ], + "nsInfo": [{"ns": ns}], + }, + ) + assertSuccess( + execute_command(collection, {"find": collection.name, "filter": {"_id": 1}}), + [{"_id": 1, "x": 42}], + msg="bulkWrite should resolve a per-statement constant in a pipeline update to its value", + ) + + +def test_bulkWrite_let_not_resolved_in_modifier_update(collection): + """Test a let $$var is stored literally (not resolved) in a non-pipeline $set update.""" + collection.insert_one({"_id": 1, "x": 1}) + ns = f"{collection.database.name}.{collection.name}" + execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": [{"update": 0, "filter": {"_id": 1}, "updateMods": {"$set": {"x": "$$v"}}}], + "nsInfo": [{"ns": ns}], + "let": {"v": 99}, + }, + ) + assertSuccess( + execute_command(collection, {"find": collection.name, "filter": {"_id": 1}}), + [{"_id": 1, "x": "$$v"}], + msg="bulkWrite modifier update should store the literal '$$v', not resolve the let var", + ) + + +def test_bulkWrite_undefined_let_variable_op_error(collection): + """Test referencing an undefined let variable yields an op-level error (code 17276).""" + collection.insert_one({"_id": 1, "x": 1}) + ns = f"{collection.database.name}.{collection.name}" + result = execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"$expr": {"$eq": ["$x", "$$missing"]}}, + "updateMods": {"$set": {"hit": True}}, + } + ], + "nsInfo": [{"ns": ns}], + }, + ) + assertResult( + result, + expected={ + "ok": Eq(1.0), + "nErrors": Eq(1), + "cursor.firstBatch.0.code": Eq(LET_UNDEFINED_VARIABLE_ERROR), + }, + raw_res=True, + msg="bulkWrite should report an undefined let variable as op error code 17276", + ) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_mixed_operations.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_mixed_operations.py new file mode 100644 index 000000000..77d1d3ea5 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_mixed_operations.py @@ -0,0 +1,208 @@ +"""Tests for bulkWrite mixed and multi-namespace operations.""" + +from documentdb_tests.framework.assertions import assertSuccess, assertSuccessPartial +from documentdb_tests.framework.executor import execute_admin_command, execute_command + + +def test_bulkWrite_insert_update_delete_mixed(collection): + """Test bulkWrite combines insert, update, and delete in one command.""" + collection.insert_one({"_id": 1, "x": 10}) + ns = f"{collection.database.name}.{collection.name}" + result = execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 2, "x": 20}}, + {"update": 0, "filter": {"_id": 1}, "updateMods": {"$set": {"x": 99}}}, + {"delete": 0, "filter": {"_id": 2}}, + ], + "nsInfo": [{"ns": ns}], + }, + ) + assertSuccessPartial( + result, + {"ok": 1.0, "nInserted": 1, "nMatched": 1, "nModified": 1, "nDeleted": 1}, + msg="bulkWrite should combine insert, update, and delete in one command", + ) + + +def test_bulkWrite_operations_across_multiple_namespaces(collection): + """Test bulkWrite operates across multiple namespaces listed in the nsInfo array.""" + sibling = collection.database[f"{collection.name}_b"] + sibling.drop() + ns = f"{collection.database.name}.{collection.name}" + ns_b = f"{collection.database.name}.{sibling.name}" + result = execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 1, "src": "a"}}, + {"insert": 1, "document": {"_id": 1, "src": "b"}}, + ], + "nsInfo": [{"ns": ns}, {"ns": ns_b}], + }, + ) + assertSuccessPartial( + result, + {"ok": 1.0, "nInserted": 2}, + msg="bulkWrite should operate across multiple namespaces in the nsInfo array", + ) + sibling.drop() + + +def test_bulkWrite_ops_reference_correct_namespace_by_index(collection): + """Test bulkWrite ops target the namespace selected by their index into nsInfo.""" + sibling = collection.database[f"{collection.name}_b"] + sibling.drop() + collection.insert_one({"_id": 1, "x": 1}) + ns = f"{collection.database.name}.{collection.name}" + ns_b = f"{collection.database.name}.{sibling.name}" + result = execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": [ + {"insert": 1, "document": {"_id": 10, "y": 1}}, + {"update": 0, "filter": {"_id": 1}, "updateMods": {"$set": {"x": 99}}}, + ], + "nsInfo": [{"ns": ns}, {"ns": ns_b}], + }, + ) + assertSuccessPartial( + result, + {"ok": 1.0, "nInserted": 1, "nMatched": 1, "nModified": 1}, + msg="bulkWrite ops should reference the correct namespace by index", + ) + sibling.drop() + + +def test_bulkWrite_interleaved_namespaces(collection): + """Test bulkWrite executes operations interleaved across multiple namespaces.""" + sibling = collection.database[f"{collection.name}_b"] + sibling.drop() + ns = f"{collection.database.name}.{collection.name}" + ns_b = f"{collection.database.name}.{sibling.name}" + result = execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 1}}, + {"insert": 1, "document": {"_id": 1}}, + {"insert": 0, "document": {"_id": 2}}, + {"insert": 1, "document": {"_id": 2}}, + ], + "nsInfo": [{"ns": ns}, {"ns": ns_b}], + }, + ) + assertSuccessPartial( + result, + {"ok": 1.0, "nInserted": 4}, + msg="bulkWrite should execute operations interleaved across namespaces", + ) + sibling.drop() + + +def _run_routing_bulkwrite(collection, sibling): + """Run a 2-namespace bulkWrite with overlapping _ids to catch mis-routing via content.""" + sibling.drop() + collection.insert_one({"_id": 1, "x": 1}) # seeds ns0 only + ns = f"{collection.database.name}.{collection.name}" + ns_b = f"{collection.database.name}.{sibling.name}" + execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 2, "tag": "a"}}, # ns0 + {"update": 0, "filter": {"_id": 1}, "updateMods": {"$set": {"x": 99}}}, # ns0 + {"insert": 1, "document": {"_id": 1, "tag": "b"}}, # ns1 (same _id as ns0) + {"insert": 1, "document": {"_id": 2, "tag": "b2"}}, # ns1 (same _id as ns0) + ], + "nsInfo": [{"ns": ns}, {"ns": ns_b}], + }, + ) + + +def test_bulkWrite_routes_index0_ops_to_namespace0_read_back(collection): + """Test index-0 ops land only in namespace 0, verified by read-back.""" + sibling = collection.database[f"{collection.name}_b"] + _run_routing_bulkwrite(collection, sibling) + assertSuccess( + execute_command(collection, {"find": collection.name, "filter": {}, "sort": {"_id": 1}}), + [{"_id": 1, "x": 99}, {"_id": 2, "tag": "a"}], + msg="bulkWrite ops with namespace index 0 should land only in namespace 0", + ) + + +def test_bulkWrite_routes_index1_ops_to_namespace1_read_back(collection): + """Test index-1 ops land only in namespace 1, verified by read-back.""" + sibling = collection.database[f"{collection.name}_b"] + _run_routing_bulkwrite(collection, sibling) + assertSuccess( + execute_command(sibling, {"find": sibling.name, "filter": {}, "sort": {"_id": 1}}), + [{"_id": 1, "tag": "b"}, {"_id": 2, "tag": "b2"}], + msg="bulkWrite ops with namespace index 1 should land only in namespace 1", + ) + + +def _seed_two_namespaces(collection, sibling): + """Seed ns0 with {_id:1,x:1} and {_id:50}, ns1 with {_id:1,y:1}; return (ns0, ns1).""" + sibling.drop() + collection.insert_many([{"_id": 1, "x": 1}, {"_id": 50}]) + sibling.insert_one({"_id": 1, "y": 1}) + ns = f"{collection.database.name}.{collection.name}" + ns_b = f"{collection.database.name}.{sibling.name}" + return ns, ns_b + + +def _mixed_cross_ns_ops(): + """Insert+update+delete across two namespaces, with a dup-key failure at idx 2.""" + return [ + {"insert": 0, "document": {"_id": 2, "v": "i0"}}, # ns0 insert — good + {"update": 1, "filter": {"_id": 1}, "updateMods": {"$set": {"y": 99}}}, # ns1 update — good + {"insert": 0, "document": {"_id": 1, "v": "dup"}}, # ns0 insert — dup key, FAILS (idx 2) + {"delete": 0, "filter": {"_id": 50}}, # ns0 delete — good, but after the failure + ] + + +def test_bulkWrite_ordered_true_skips_delete_after_mid_batch_failure(collection): + """Test ordered:true mixed-op/multi-ns batch stops at the failure, skipping the later delete.""" + sibling = collection.database[f"{collection.name}_b"] + ns, ns_b = _seed_two_namespaces(collection, sibling) + execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": _mixed_cross_ns_ops(), + "nsInfo": [{"ns": ns}, {"ns": ns_b}], + "ordered": True, + }, + ) + assertSuccess( + execute_command(collection, {"find": collection.name, "filter": {}, "sort": {"_id": 1}}), + [{"_id": 1, "x": 1}, {"_id": 2, "v": "i0"}, {"_id": 50}], + msg="ordered:true should skip the delete after the mid-batch failure (_id:50 kept)", + ) + + +def test_bulkWrite_ordered_false_runs_delete_after_mid_batch_failure(collection): + """Test ordered:false mixed-op/multi-ns batch runs the later delete despite the failure.""" + sibling = collection.database[f"{collection.name}_b"] + ns, ns_b = _seed_two_namespaces(collection, sibling) + execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": _mixed_cross_ns_ops(), + "nsInfo": [{"ns": ns}, {"ns": ns_b}], + "ordered": False, + }, + ) + assertSuccess( + execute_command(collection, {"find": collection.name, "filter": {}, "sort": {"_id": 1}}), + [{"_id": 1, "x": 1}, {"_id": 2, "v": "i0"}], + msg="ordered:false should run the delete after the mid-batch failure (_id:50 removed)", + ) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_response_structure.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_response_structure.py new file mode 100644 index 000000000..819a91b04 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_response_structure.py @@ -0,0 +1,200 @@ +"""Tests for bulkWrite response structure and cursor behavior.""" + +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.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, Gt, IsType, Len + +BULKWRITE_RESPONSE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "response_contains_cursor_id", + command={"bulkWrite": 1, "ops": [{"insert": 0, "document": {"_id": 1}}]}, + expected={"cursor.id": IsType("long")}, + msg="bulkWrite response should contain a long cursor.id", + ), + CommandTestCase( + "response_contains_cursor_ns", + command={"bulkWrite": 1, "ops": [{"insert": 0, "document": {"_id": 1}}]}, + expected={"ok": Eq(1.0), "cursor.ns": Eq("admin.$cmd.bulkWrite")}, + msg="bulkWrite response cursor.ns should be admin.$cmd.bulkWrite", + ), + CommandTestCase( + "response_contains_firstBatch", + command={"bulkWrite": 1, "ops": [{"insert": 0, "document": {"_id": 1}}]}, + expected={ + "ok": Eq(1.0), + "cursor.firstBatch.0.ok": Eq(1.0), + "cursor.firstBatch.0.idx": Eq(0), + "cursor.firstBatch.0.n": Eq(1), + }, + msg="bulkWrite response should contain a cursor.firstBatch array", + ), + CommandTestCase( + "response_nErrors_zero_on_success", + command={"bulkWrite": 1, "ops": [{"insert": 0, "document": {"_id": 1}}]}, + expected={"ok": Eq(1.0), "nErrors": Eq(0)}, + msg="bulkWrite response should contain nErrors:0 on success", + ), + CommandTestCase( + "response_nInserted", + command={ + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 1}}, + {"insert": 0, "document": {"_id": 2}}, + ], + }, + expected={"ok": Eq(1.0), "nInserted": Eq(2)}, + msg="bulkWrite response should contain nInserted", + ), + CommandTestCase( + "response_nMatched_nModified", + docs=[{"_id": 1, "x": 10}], + command={ + "bulkWrite": 1, + "ops": [{"update": 0, "filter": {"_id": 1}, "updateMods": {"$set": {"x": 20}}}], + }, + expected={"ok": Eq(1.0), "nMatched": Eq(1), "nModified": Eq(1)}, + msg="bulkWrite response should contain nMatched and nModified", + ), + CommandTestCase( + "response_matched_but_unmodified", + docs=[{"_id": 1, "x": 10}], + command={ + "bulkWrite": 1, + # $set to the SAME value: matches the doc but changes nothing. + "ops": [{"update": 0, "filter": {"_id": 1}, "updateMods": {"$set": {"x": 10}}}], + }, + expected={ + "ok": Eq(1.0), + "nMatched": Eq(1), + "nModified": Eq(0), + "cursor.firstBatch.0.ok": Eq(1.0), + "cursor.firstBatch.0.idx": Eq(0), + "cursor.firstBatch.0.n": Eq(1), + "cursor.firstBatch.0.nModified": Eq(0), + }, + msg="bulkWrite no-op update should report nMatched:1/nModified:0 and per-op nModified:0", + ), + CommandTestCase( + "response_nDeleted", + docs=[{"_id": 1}], + command={"bulkWrite": 1, "ops": [{"delete": 0, "filter": {"_id": 1}}]}, + expected={"ok": Eq(1.0), "nDeleted": Eq(1)}, + msg="bulkWrite response should contain nDeleted", + ), + CommandTestCase( + "errorsOnly_false_returns_all_results", + command={ + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 1}}, + {"insert": 0, "document": {"_id": 2}}, + ], + "errorsOnly": False, + }, + expected={ + "ok": Eq(1.0), + "nInserted": Eq(2), + "cursor.firstBatch.0.ok": Eq(1.0), + "cursor.firstBatch.0.idx": Eq(0), + "cursor.firstBatch.0.n": Eq(1), + "cursor.firstBatch.1.ok": Eq(1.0), + "cursor.firstBatch.1.idx": Eq(1), + "cursor.firstBatch.1.n": Eq(1), + }, + msg="bulkWrite errorsOnly:false should return all operation results", + ), + CommandTestCase( + "errorsOnly_true_full_success_empty_firstBatch", + command={ + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 1}}, + {"insert": 0, "document": {"_id": 2}}, + ], + "errorsOnly": True, + }, + expected={"ok": Eq(1.0), "nInserted": Eq(2), "nErrors": Eq(0), "cursor.firstBatch": Len(0)}, + msg="bulkWrite errorsOnly:true on full success should return an empty firstBatch", + ), + CommandTestCase( + "cursor_batchSize_1", + command={ + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 1}}, + {"insert": 0, "document": {"_id": 2}}, + {"insert": 0, "document": {"_id": 3}}, + ], + "cursor": {"batchSize": 1}, + "errorsOnly": False, + }, + expected={"cursor.firstBatch": Len(1)}, + msg="bulkWrite cursor.batchSize:1 should limit firstBatch to one result", + ), + CommandTestCase( + "cursor_batchSize_zero", + command={ + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 1}}, + {"insert": 0, "document": {"_id": 2}}, + ], + "cursor": {"batchSize": 0}, + "errorsOnly": False, + }, + expected={"cursor.firstBatch": Len(0), "cursor.id": Gt(0)}, + msg="bulkWrite cursor.batchSize:0 should return an empty firstBatch with a live cursor", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(BULKWRITE_RESPONSE_TESTS)) +def test_bulkWrite_response_structure(database_client, collection, test): + """Test bulkWrite response structure and cursor behavior.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + command = test.build_command(ctx) + if "nsInfo" not in command: + command = {**command, "nsInfo": [{"ns": ctx.namespace}]} + result = execute_admin_command(collection, command) + assertResult(result, expected=test.build_expected(ctx), msg=test.msg, raw_res=True) + + +def test_bulkWrite_getMore_drains_remaining_results(collection): + """Test a batched bulkWrite cursor (batchSize:1) is drained with getMore into nextBatch.""" + ns = f"{collection.database.name}.{collection.name}" + result = execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 1}}, + {"insert": 0, "document": {"_id": 2}}, + {"insert": 0, "document": {"_id": 3}}, + ], + "nsInfo": [{"ns": ns}], + "errorsOnly": False, + "cursor": {"batchSize": 1}, + }, + ) + cursor_id = result["cursor"]["id"] + more = execute_admin_command(collection, {"getMore": cursor_id, "collection": "$cmd.bulkWrite"}) + assertResult( + more, + expected={ + "ok": Eq(1.0), + "cursor.nextBatch": Len(2), + "cursor.nextBatch.0.idx": Eq(1), + "cursor.nextBatch.1.idx": Eq(2), + }, + raw_res=True, + msg="bulkWrite getMore should drain the remaining op results into nextBatch", + ) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_sub_features.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_sub_features.py new file mode 100644 index 000000000..e6e8d5020 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_sub_features.py @@ -0,0 +1,203 @@ +"""Tests for bulkWrite sub-features: arrayFilters, hint, collation, and bypassDocumentValidation.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, + IndexModel, +) +from documentdb_tests.framework.assertions import assertSuccess, assertSuccessPartial +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.target_collection import CustomCollection + +BULKWRITE_SUB_FEATURE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "arrayFilters_modifies_matching_elements", + docs=[{"_id": 1, "items": [{"x": 1}, {"x": 2}, {"x": 3}]}], + command={ + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"_id": 1}, + "updateMods": {"$set": {"items.$[elem].x": 99}}, + "arrayFilters": [{"elem.x": {"$gt": 1}}], + } + ], + }, + expected={"ok": 1.0, "nMatched": 1, "nModified": 1}, + msg="bulkWrite arrayFilters should modify matching array elements", + ), + CommandTestCase( + "update_with_hint", + indexes=[IndexModel([("x", 1)], name="x_1")], + docs=[{"_id": 1, "x": 10}], + command={ + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"x": 10}, + "updateMods": {"$set": {"x": 20}}, + "hint": "x_1", + } + ], + }, + expected={"ok": 1.0, "nMatched": 1, "nModified": 1}, + msg="bulkWrite update with a valid hint should succeed", + ), + CommandTestCase( + "delete_with_hint", + indexes=[IndexModel([("x", 1)], name="x_1")], + docs=[{"_id": 1, "x": 10}], + command={ + "bulkWrite": 1, + "ops": [{"delete": 0, "filter": {"x": 10}, "hint": "x_1"}], + }, + expected={"ok": 1.0, "nDeleted": 1}, + msg="bulkWrite delete with a valid hint should succeed", + ), + CommandTestCase( + "update_with_collation", + # Comparison semantics are owned by tests/core/collation/; this only checks wiring. + docs=[{"_id": 1, "name": "café"}, {"_id": 2, "name": "cafe"}], + command={ + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"name": "cafe"}, + "updateMods": {"$set": {"matched": True}}, + "collation": {"locale": "en", "strength": 1}, + "multi": True, + } + ], + }, + expected={"ok": 1.0, "nMatched": 2, "nModified": 2}, + msg="bulkWrite should forward a per-op collation to the update (wiring check)", + ), + CommandTestCase( + "bypassDocumentValidation_true", + target_collection=CustomCollection( + options={"validator": {"$jsonSchema": {"required": ["name"]}}} + ), + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1, "x": 1}}], # missing "name" + "bypassDocumentValidation": True, + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite bypassDocumentValidation:true should allow a validator-violating write", + ), + CommandTestCase( + "update_mixed_collation", + docs=[{"_id": 1, "name": "Apple", "v": 1}, {"_id": 2, "name": "apple", "v": 1}], + command={ + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"name": "apple"}, + "updateMods": {"$set": {"v": 2}}, + "collation": {"locale": "en", "strength": 2}, + "multi": True, + }, + { + "update": 0, + "filter": {"name": "Apple"}, + "updateMods": {"$set": {"v": 3}}, + }, + ], + }, + # First op matches both (case-insensitive), second matches only exact "Apple". + expected={"ok": 1.0, "nMatched": 3, "nModified": 3}, + msg="bulkWrite collation on one op should not leak to another op", + ), + CommandTestCase( + "collection_uuid_match", + docs=[{"_id": 0}], + command=lambda ctx: { + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "nsInfo": [{"ns": ctx.namespace, "collectionUUID": ctx.uuids[ctx.collection]}], + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite with a collectionUUID matching the target collection should succeed", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(BULKWRITE_SUB_FEATURE_TESTS)) +def test_bulkWrite_sub_features(database_client, collection, test): + """Test bulkWrite sub-features: arrayFilters, hint, collation, and bypassDocumentValidation.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + command = test.build_command(ctx) + if "nsInfo" not in command: + command = {**command, "nsInfo": [{"ns": ctx.namespace}]} + result = execute_admin_command(collection, command) + assertSuccessPartial(result, test.build_expected(ctx), msg=test.msg) + + +def test_bulkWrite_arrayFilters_modifies_correct_nested_elements(collection): + """Test a valid arrayFilters + $[identifier] update changes only matching nested elements.""" + collection.insert_one( + {"_id": 1, "items": [{"x": 1, "k": "a"}, {"x": 2, "k": "b"}, {"x": 3, "k": "c"}]} + ) + ns = f"{collection.database.name}.{collection.name}" + execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"_id": 1}, + "updateMods": {"$set": {"items.$[elem].x": 99}}, + "arrayFilters": [{"elem.x": {"$gt": 1}}], + } + ], + "nsInfo": [{"ns": ns}], + }, + ) + assertSuccess( + execute_command(collection, {"find": collection.name, "filter": {"_id": 1}}), + [{"_id": 1, "items": [{"x": 1, "k": "a"}, {"x": 99, "k": "b"}, {"x": 99, "k": "c"}]}], + msg="bulkWrite arrayFilters should modify only the elements matching the identifier " + "predicate and leave the rest unchanged", + ) + + +def test_bulkWrite_collation_affects_arrayFilters_selection(collection): + """Test op-level collation makes arrayFilters match array elements case-insensitively. + + Which array elements arrayFilters changed is only observable via read-back. With a + strength:2 (case-insensitive) collation, the filter {e: "apple"} matches BOTH "Apple" and + "apple"; without collation it would match only the exact "apple". This interaction is not + exercised by the sibling arrayFilters suite. + """ + collection.insert_one({"_id": 1, "items": ["Apple", "apple", "banana"]}) + ns = f"{collection.database.name}.{collection.name}" + execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"_id": 1}, + "updateMods": {"$set": {"items.$[e]": "X"}}, + "arrayFilters": [{"e": "apple"}], + "collation": {"locale": "en", "strength": 2}, + } + ], + "nsInfo": [{"ns": ns}], + }, + ) + assertSuccess( + execute_command(collection, {"find": collection.name, "filter": {"_id": 1}}), + [{"_id": 1, "items": ["X", "X", "banana"]}], + msg="bulkWrite collation should make arrayFilters match case-insensitively (both Apples)", + ) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_argument_validation.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_argument_validation.py new file mode 100644 index 000000000..53b7b02d6 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_argument_validation.py @@ -0,0 +1,190 @@ +"""Tests for findAndModify argument handling — success cases for valid parameter variants.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, + IndexModel, +) +from documentdb_tests.framework.assertions import assertResult, assertSuccessPartial +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq + +ALL_TESTS = [ + CommandTestCase( + "hint-string-existing-index", + docs=[{"_id": 1, "x": 10}], + indexes=[IndexModel("x", name="x_1")], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 20}}, + "hint": "x_1", + "new": True, + }, + expected={"value": Eq({"_id": 1, "x": 20})}, + msg="findAndModify with hint as index-name string succeeds", + ), + CommandTestCase( + "hint-spec-document", + docs=[{"_id": 1, "x": 10}], + indexes=[IndexModel("x")], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 20}}, + "hint": {"x": 1}, + "new": True, + }, + expected={"value": Eq({"_id": 1, "x": 20})}, + msg="findAndModify with hint as index-specification document succeeds", + ), + CommandTestCase( + "empty-query-selects-document", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {}, + "update": {"$set": {"x": 20}}, + }, + expected={"value": Eq({"_id": 1, "x": 10})}, + msg="findAndModify with empty query {} selects a document", + ), + CommandTestCase( + "write-concern-w1", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 20}}, + "writeConcern": {"w": 1}, + "new": True, + }, + expected={"value": Eq({"_id": 1, "x": 20})}, + msg="findAndModify with writeConcern w:1 succeeds", + ), + CommandTestCase( + "comment-string", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 20}}, + "comment": "test comment", + }, + expected={ + "lastErrorObject": Eq({"n": 1, "updatedExisting": True}), + "value": Eq({"_id": 1, "x": 10}), + }, + msg="findAndModify with comment as string succeeds", + ), + CommandTestCase( + "maxTimeMS-zero", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 20}}, + "maxTimeMS": 0, + }, + expected={ + "lastErrorObject": Eq({"n": 1, "updatedExisting": True}), + "value": Eq({"_id": 1, "x": 10}), + }, + msg="findAndModify with maxTimeMS:0 succeeds (unbounded)", + ), + CommandTestCase( + "sort-empty-document", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 20}}, + "sort": {}, + }, + expected={"value": Eq({"_id": 1, "x": 10})}, + msg="findAndModify with sort:{} is accepted (no sort applied)", + ), + CommandTestCase( + "fields-empty-document-returns-full-doc", + docs=[{"_id": 1, "x": 10, "y": 20}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 99}}, + "fields": {}, + }, + expected={"value": Eq({"_id": 1, "x": 10, "y": 20})}, + msg="findAndModify with fields:{} returns the full document", + ), + CommandTestCase( + "hint-empty-document", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 20}}, + "hint": {}, + "new": True, + }, + expected={"value": Eq({"_id": 1, "x": 20})}, + msg="findAndModify with hint:{} empty document succeeds", + ), + CommandTestCase( + "bypass-validation-as-int", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 20}}, + "bypassDocumentValidation": 1, + }, + expected={ + "lastErrorObject": Eq({"n": 1, "updatedExisting": True}), + "value": Eq({"_id": 1, "x": 10}), + }, + msg="findAndModify accepts bypassDocumentValidation as integer", + ), + CommandTestCase( + "bypass-validation-as-null", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 20}}, + "bypassDocumentValidation": None, + }, + expected={ + "lastErrorObject": Eq({"n": 1, "updatedExisting": True}), + "value": Eq({"_id": 1, "x": 10}), + }, + msg="findAndModify accepts bypassDocumentValidation as null", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_findAndModify_argument_validation(database_client, collection, test): + """Test findAndModify argument handling - success cases for valid parameter variants.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + command = {"findAndModify": collection.name, **test.build_command(ctx)} + result = execute_command(collection, command) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + + +def test_findAndModify_bypass_validation_allows_invalid_write(database_client, request): + """Test bypassDocumentValidation:true allows write that violates validator.""" + db = database_client + coll_name = f"{request.node.name}_validated" + db.create_collection(coll_name, validator={"$jsonSchema": {"required": ["name"]}}) + coll = db[coll_name] + coll.insert_one({"_id": 1, "name": "test"}) + result = execute_command( + coll, + { + "findAndModify": coll.name, + "query": {"_id": 1}, + "update": {"$unset": {"name": ""}}, + "bypassDocumentValidation": True, + "new": True, + }, + ) + assertSuccessPartial(result, {"value": {"_id": 1}}) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_bson_type_validation.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_bson_type_validation.py new file mode 100644 index 000000000..7200ddff3 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_bson_type_validation.py @@ -0,0 +1,240 @@ +""" +BSON type validation tests for findAndModify command parameters. + +Verifies that findAndModify correctly rejects invalid BSON types for all +parameters and accepts valid 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 ( + FAILED_TO_PARSE_ERROR, + INVALID_NAMESPACE_ERROR, + TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.executor import execute_command + +BSON_PARAMS = [ + BsonTypeTestCase( + id="findAndModify", + msg="findAndModify collection name should reject non-string types", + keyword="findAndModify", + valid_types=[BsonType.STRING], + default_error_code=INVALID_NAMESPACE_ERROR, + ), + BsonTypeTestCase( + id="query", + msg="findAndModify query should reject non-document types", + keyword="query", + valid_types=[BsonType.OBJECT, BsonType.NULL], + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="sort", + msg="findAndModify sort should reject non-document types", + keyword="sort", + valid_types=[BsonType.OBJECT, BsonType.NULL], + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="update", + msg="findAndModify update should reject non-document/array types", + keyword="update", + valid_types=[BsonType.OBJECT, BsonType.ARRAY], + valid_inputs={ + BsonType.OBJECT: {"$set": {"x": 1}}, + BsonType.ARRAY: [{"$set": {"x": 1}}], + }, + default_error_code=FAILED_TO_PARSE_ERROR, + ), + BsonTypeTestCase( + id="fields", + msg="findAndModify fields should reject non-document types", + keyword="fields", + valid_types=[BsonType.OBJECT, BsonType.NULL], + valid_inputs={BsonType.OBJECT: {"x": 1}, BsonType.NULL: None}, + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="remove", + msg="findAndModify remove should reject non-numeric/non-bool types", + keyword="remove", + valid_types=[ + BsonType.BOOL, + BsonType.INT, + BsonType.LONG, + BsonType.DOUBLE, + BsonType.DECIMAL, + BsonType.NULL, + ], + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="new", + msg="findAndModify new should reject non-numeric/non-bool types", + keyword="new", + valid_types=[ + BsonType.BOOL, + BsonType.INT, + BsonType.LONG, + BsonType.DOUBLE, + BsonType.DECIMAL, + BsonType.NULL, + ], + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="upsert", + msg="findAndModify upsert should reject non-numeric/non-bool types", + keyword="upsert", + valid_types=[ + BsonType.BOOL, + BsonType.INT, + BsonType.LONG, + BsonType.DOUBLE, + BsonType.DECIMAL, + BsonType.NULL, + ], + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="bypassDocumentValidation", + msg="findAndModify bypassDocumentValidation should reject non-numeric/non-bool types", + keyword="bypassDocumentValidation", + valid_types=[ + BsonType.BOOL, + BsonType.INT, + BsonType.LONG, + BsonType.DOUBLE, + BsonType.DECIMAL, + BsonType.NULL, + ], + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="maxTimeMS", + msg="findAndModify maxTimeMS should reject non-numeric types", + keyword="maxTimeMS", + valid_types=[ + BsonType.INT, + BsonType.LONG, + BsonType.DOUBLE, + BsonType.DECIMAL, + BsonType.NULL, + ], + valid_inputs={ + BsonType.INT: 100, + BsonType.LONG: Int64(100), + BsonType.DOUBLE: 100.0, + BsonType.DECIMAL: Decimal128("100"), + BsonType.NULL: None, + }, + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="collation", + msg="findAndModify collation should reject non-document types", + keyword="collation", + valid_types=[BsonType.OBJECT, BsonType.NULL], + valid_inputs={BsonType.OBJECT: {"locale": "en"}, BsonType.NULL: None}, + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="writeConcern", + msg="findAndModify writeConcern should reject non-document types", + keyword="writeConcern", + valid_types=[BsonType.OBJECT, BsonType.NULL], + valid_inputs={BsonType.OBJECT: {"w": 1}, BsonType.NULL: None}, + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="let", + msg="findAndModify let should reject non-document types", + keyword="let", + valid_types=[BsonType.OBJECT, BsonType.NULL], + valid_inputs={BsonType.OBJECT: {"a": 1}, BsonType.NULL: None}, + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="arrayFilters", + msg="findAndModify arrayFilters should reject non-array types", + keyword="arrayFilters", + valid_types=[BsonType.ARRAY, BsonType.NULL], + valid_inputs={BsonType.ARRAY: [], BsonType.NULL: None}, + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="hint", + msg="findAndModify hint should reject non-document/non-string types", + keyword="hint", + valid_types=[BsonType.OBJECT, BsonType.STRING], + valid_inputs={ + BsonType.OBJECT: {"_id": 1}, + BsonType.STRING: "_id_", + }, + default_error_code=FAILED_TO_PARSE_ERROR, + ), + BsonTypeTestCase( + id="comment", + msg="findAndModify comment should accept all BSON types", + keyword="comment", + valid_types=list(BsonType), + ), +] + +REJECTION_TESTS = generate_bson_rejection_test_cases(BSON_PARAMS) +ACCEPTANCE_TESTS = generate_bson_acceptance_test_cases(BSON_PARAMS) + + +def _build_command(collection_name, spec, sample_value): + """Build a findAndModify command with sample_value placed at spec.keyword.""" + cmd = { + "findAndModify": collection_name, + "query": {"_id": 1}, + "update": {"$set": {"x": 1}}, + spec.keyword: sample_value, + } + if spec.id == "remove" and sample_value: + cmd.pop("update", None) + return cmd + + +def _build_expected(spec, sample_value): + """Build expected partial result based on which parameter is being tested.""" + # These keywords replace query or collection name, so the doc may not be found + if spec.keyword in ("query", "findAndModify"): + return {"ok": 1.0} + # remove with truthy value deletes the matched doc + if spec.id == "remove" and sample_value: + return {"ok": 1.0, "lastErrorObject": {"n": 1}, "value": {"_id": 1, "x": 10}} + # All other keywords: query is {_id:1} which matches, update runs + return {"ok": 1.0, "lastErrorObject": {"n": 1, "updatedExisting": True}} + + +@pytest.mark.parametrize("bson_type,sample_value,spec", REJECTION_TESTS) +def test_findAndModify_bson_type_rejected(collection, bson_type, sample_value, spec): + """Verifies findAndModify rejects invalid BSON types for each parameter.""" + collection.insert_one({"_id": 1, "x": 10}) + result = execute_command(collection, _build_command(collection.name, spec, sample_value)) + assertFailureCode(result, spec.expected_code(bson_type), msg=spec.msg) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", ACCEPTANCE_TESTS) +def test_findAndModify_bson_type_accepted(collection, bson_type, sample_value, spec): + """Verifies findAndModify accepts valid BSON types for each parameter.""" + collection.insert_one({"_id": 1, "x": 10}) + result = execute_command(collection, _build_command(collection.name, spec, sample_value)) + assertSuccessPartial( + result, + _build_expected(spec, sample_value), + msg=f"findAndModify should accept {bson_type.value} for {spec.keyword}", + ) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_core_behavior.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_core_behavior.py new file mode 100644 index 000000000..cf0818571 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_core_behavior.py @@ -0,0 +1,491 @@ +""" +Tests for findAndModify core behavior: update, remove, upsert, new flag, +and sort selection. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult, assertSuccess, assertSuccessPartial +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq + +NEW_FLAG_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "update-new-false-returns-pre-image", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 20}}, + "new": False, + }, + expected={ + "lastErrorObject": Eq({"n": 1, "updatedExisting": True}), + "value": Eq({"_id": 1, "x": 10}), + "ok": Eq(1.0), + }, + msg="update with new:false returns pre-modification document", + ), + CommandTestCase( + "update-new-true-returns-post-image", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 20}}, + "new": True, + }, + expected={ + "lastErrorObject": Eq({"n": 1, "updatedExisting": True}), + "value": Eq({"_id": 1, "x": 20}), + "ok": Eq(1.0), + }, + msg="update with new:true returns post-modification document", + ), + CommandTestCase( + "new-omitted-defaults-to-pre-image", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 20}}, + }, + expected={"value": Eq({"_id": 1, "x": 10})}, + msg="new omitted defaults to returning pre-modification document", + ), + CommandTestCase( + "new-true-noop-update-returns-doc", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 10}}, + "new": True, + }, + expected={ + "lastErrorObject": Eq({"n": 1, "updatedExisting": True}), + "value": Eq({"_id": 1, "x": 10}), + }, + msg="new:true on noop update still returns doc with updatedExisting", + ), +] + +REMOVE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "remove-returns-removed-document", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "remove": True, + }, + expected={ + "lastErrorObject": Eq({"n": 1}), + "value": Eq({"_id": 1, "x": 10}), + "ok": Eq(1.0), + }, + msg="remove returns the removed document", + ), + CommandTestCase( + "remove-no-match-returns-null", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 999}, + "remove": True, + }, + expected={ + "lastErrorObject": Eq({"n": 0}), + "value": Eq(None), + "ok": Eq(1.0), + }, + msg="remove with no match returns value:null", + ), + CommandTestCase( + "remove-empty-query-deletes-one", + docs=[{"_id": 1, "x": 10}, {"_id": 2, "x": 20}], + command={ + "query": {}, + "remove": True, + }, + expected={"lastErrorObject": Eq({"n": 1}), "ok": Eq(1.0)}, + msg="remove with empty query deletes one document", + ), + CommandTestCase( + "remove-descending-id-sort", + docs=[{"_id": 1, "x": 10}, {"_id": 2, "x": 20}, {"_id": 3, "x": 30}], + command={ + "query": {}, + "remove": True, + "sort": {"_id": -1}, + }, + expected={"value": Eq({"_id": 3, "x": 30})}, + msg="remove with sort:{_id:-1} removes highest _id", + ), + CommandTestCase( + "remove-ascending-id-sort", + docs=[{"_id": 1, "x": 10}, {"_id": 2, "x": 20}, {"_id": 3, "x": 30}], + command={ + "query": {}, + "remove": True, + "sort": {"_id": 1}, + }, + expected={"value": Eq({"_id": 1, "x": 10})}, + msg="remove with sort:{_id:1} removes lowest _id", + ), + CommandTestCase( + "remove-nested-field-sort", + docs=[ + {"_id": 1, "a": {"b": 30}}, + {"_id": 2, "a": {"b": 10}}, + {"_id": 3, "a": {"b": 20}}, + ], + command={ + "query": {}, + "remove": True, + "sort": {"a.b": -1}, + }, + expected={"value": Eq({"_id": 1, "a": {"b": 30}})}, + msg="remove with sort on nested field path selects correct doc", + ), + CommandTestCase( + "remove-with-query-and-sort", + docs=[ + {"_id": 1, "x": 5}, + {"_id": 2, "x": 15}, + {"_id": 3, "x": 25}, + ], + command={ + "query": {"x": {"$gt": 10}}, + "remove": True, + "sort": {"x": -1}, + }, + expected={"value": Eq({"_id": 3, "x": 25})}, + msg="remove with range query and descending sort selects highest match", + ), + CommandTestCase( + "truthy-numeric-remove", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "remove": 1, + }, + expected={ + "value": Eq({"_id": 1, "x": 10}), + "lastErrorObject": Eq({"n": 1}), + }, + msg="accepts truthy numeric value for remove flag", + ), +] + +UPSERT_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "upsert-inserts-new-document", + docs=[], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 10}}, + "upsert": True, + }, + expected={ + "lastErrorObject": Eq({"n": 1, "updatedExisting": False, "upserted": 1}), + "ok": Eq(1.0), + }, + msg="upsert inserts when no match; lastErrorObject has upserted", + ), +] + +SORT_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "sort-ascending-selects-first", + docs=[{"_id": 1, "x": 30}, {"_id": 2, "x": 10}, {"_id": 3, "x": 20}], + command={ + "query": {}, + "update": {"$set": {"modified": True}}, + "sort": {"x": 1}, + }, + expected={"value": Eq({"_id": 2, "x": 10})}, + msg="sort:{x:1} selects lowest x value", + ), + CommandTestCase( + "sort-descending-selects-highest", + docs=[{"_id": 1, "x": 30}, {"_id": 2, "x": 10}, {"_id": 3, "x": 20}], + command={ + "query": {}, + "update": {"$set": {"modified": True}}, + "sort": {"x": -1}, + }, + expected={"value": Eq({"_id": 1, "x": 30})}, + msg="sort:{x:-1} selects highest x value", + ), + CommandTestCase( + "sort-with-update-returns-pre-image", + docs=[ + {"_id": 1, "priority": 1, "count": 0}, + {"_id": 2, "priority": 5, "count": 0}, + {"_id": 3, "priority": 3, "count": 0}, + ], + command={ + "query": {}, + "sort": {"priority": -1}, + "update": {"$inc": {"count": 1}, "$set": {"active": True}}, + }, + expected={"value": Eq({"_id": 2, "priority": 5, "count": 0})}, + msg="sort with multi-operator update returns pre-image by default", + ), + CommandTestCase( + "sort-descending-updates-highest", + docs=[ + {"_id": 1, "priority": 1}, + {"_id": 2, "priority": 2}, + {"_id": 3, "priority": 3}, + ], + command={ + "query": {}, + "sort": {"priority": -1}, + "update": {"$set": {"done": True}}, + }, + expected={"value": Eq({"_id": 3, "priority": 3})}, + msg="descending sort updates highest-ranked document", + ), + CommandTestCase( + "compound-sort", + docs=[ + {"_id": 1, "a": 1, "b": 2}, + {"_id": 2, "a": 1, "b": 1}, + {"_id": 3, "a": 2, "b": 1}, + ], + command={ + "query": {}, + "sort": {"a": 1, "b": 1}, + "update": {"$set": {"done": True}}, + }, + expected={"value": Eq({"_id": 2, "a": 1, "b": 1})}, + msg="sort with multiple fields (compound sort) selects correctly", + ), + CommandTestCase( + "sort-id-ascending-selects-min", + docs=[{"_id": 3, "x": 1}, {"_id": 1, "x": 1}, {"_id": 2, "x": 1}], + command={ + "query": {}, + "sort": {"_id": 1}, + "update": {"$set": {"done": True}}, + }, + expected={"value": Eq({"_id": 1, "x": 1})}, + msg="sort:{_id:1} selects minimum _id", + ), + CommandTestCase( + "sort-id-descending-selects-max", + docs=[{"_id": 3, "x": 1}, {"_id": 1, "x": 1}, {"_id": 2, "x": 1}], + command={ + "query": {}, + "sort": {"_id": -1}, + "update": {"$set": {"done": True}}, + }, + expected={"value": Eq({"_id": 3, "x": 1})}, + msg="sort:{_id:-1} selects maximum _id", + ), + CommandTestCase( + "sort-on-field-absent-in-some-docs", + docs=[ + {"_id": 1, "x": 10}, + {"_id": 2}, + {"_id": 3, "x": 5}, + {"_id": 4}, + ], + command={ + "query": {}, + "sort": {"x": 1}, + "update": {"$set": {"done": True}}, + }, + expected={"value": Eq({"_id": 2})}, + msg="sort on field absent in some docs: missing field sorts before present values", + ), + CommandTestCase( + "sort-descending-field-absent-selects-highest", + docs=[ + {"_id": 1, "x": 10}, + {"_id": 2}, + {"_id": 3, "x": 5}, + ], + command={ + "query": {}, + "sort": {"x": -1}, + "update": {"$set": {"done": True}}, + }, + expected={"value": Eq({"_id": 1, "x": 10})}, + msg="sort descending on field absent in some docs selects highest value", + ), + CommandTestCase( + "sort-mixed-bson-types-ascending", + docs=[ + {"_id": 1, "x": "hello"}, + {"_id": 2, "x": 10}, + {"_id": 3, "x": None}, + {"_id": 4, "x": True}, + ], + command={ + "query": {}, + "sort": {"x": 1}, + "update": {"$set": {"done": True}}, + }, + expected={"value": Eq({"_id": 3, "x": None})}, + msg="sort ascending on mixed BSON types follows BSON comparison order", + ), +] + +UPDATE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "update-no-match-returns-null", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 999}, + "update": {"$set": {"x": 20}}, + }, + expected={ + "lastErrorObject": Eq({"n": 0, "updatedExisting": False}), + "value": Eq(None), + "ok": Eq(1.0), + }, + msg="update with no match returns value:null", + ), + CommandTestCase( + "update-empty-collection-returns-null", + docs=[], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 1}}, + }, + expected={ + "lastErrorObject": Eq({"n": 0, "updatedExisting": False}), + "value": Eq(None), + "ok": Eq(1.0), + }, + msg="update on empty collection returns null", + ), + CommandTestCase( + "update-operators-inc-push", + docs=[{"_id": 1, "x": 10, "tags": ["a"]}], + command={ + "query": {"_id": 1}, + "update": {"$inc": {"x": 5}, "$push": {"tags": "b"}}, + "new": True, + }, + expected={"value": Eq({"_id": 1, "x": 15, "tags": ["a", "b"]})}, + msg="update operators ($inc/$push) apply correctly", + ), +] + +ALL_TESTS: list[CommandTestCase] = ( + NEW_FLAG_TESTS + REMOVE_TESTS + UPSERT_TESTS + SORT_TESTS + UPDATE_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_findAndModify_core(database_client, collection, test): + """Test findAndModify core behavior.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + command = {"findAndModify": collection.name, **test.build_command(ctx)} + result = execute_command(collection, command) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + + +def test_findAndModify_remove_actually_deletes_document(collection): + """Test findAndModify remove deletes the document from the collection.""" + collection.insert_one({"_id": 1, "x": 10}) + execute_command( + collection, + {"findAndModify": collection.name, "query": {"_id": 1}, "remove": True}, + ) + result = execute_command(collection, {"find": collection.name, "filter": {"_id": 1}}) + assertSuccess(result, []) + + +def test_findAndModify_upsert_new_false_still_inserts(collection): + """Test findAndModify upsert with new:false still inserts the document.""" + execute_command( + collection, + { + "findAndModify": collection.name, + "query": {"_id": 1}, + "update": {"$set": {"x": 10}}, + "upsert": True, + "new": False, + }, + ) + result = execute_command(collection, {"find": collection.name, "filter": {"_id": 1}}) + assertSuccess(result, [{"_id": 1, "x": 10}]) + + +def test_findAndModify_updates_exactly_one_document(collection): + """Test findAndModify updates only one document when query matches multiple.""" + collection.insert_many([{"_id": i, "status": "active", "x": i} for i in range(1, 6)]) + execute_command( + collection, + { + "findAndModify": collection.name, + "query": {"status": "active"}, + "sort": {"_id": 1}, + "update": {"$set": {"status": "done"}}, + }, + ) + result = execute_command(collection, {"find": collection.name, "filter": {"status": "done"}}) + assertSuccess(result, [{"_id": 1, "status": "done", "x": 1}]) + + +def test_findAndModify_empty_query_no_sort_single_mod(collection): + """Test findAndModify with empty query and no sort modifies exactly one document.""" + collection.insert_many([{"_id": i, "x": i} for i in range(1, 6)]) + result = execute_command( + collection, + { + "findAndModify": collection.name, + "query": {}, + "update": {"$set": {"done": True}}, + }, + ) + assertSuccessPartial(result, {"lastErrorObject": {"n": 1, "updatedExisting": True}}) + + +def test_findAndModify_remove_nonexistent_collection(database_client, request): + """Test findAndModify remove on non-existent collection returns null, ok:1.""" + coll = database_client[f"{request.node.name}_nonexistent"] + result = execute_command( + coll, + { + "findAndModify": coll.name, + "query": {"_id": 1}, + "remove": True, + }, + ) + assertSuccess( + result, + {"lastErrorObject": {"n": 0}, "value": None, "ok": 1.0}, + raw_res=True, + ) + + +def test_findAndModify_upsert_creates_collection(database_client, request): + """Test findAndModify upsert on non-existent collection creates it.""" + coll = database_client[f"{request.node.name}_new"] + result = execute_command( + coll, + { + "findAndModify": coll.name, + "query": {"_id": 1}, + "update": {"$set": {"x": 1}}, + "upsert": True, + "new": True, + }, + ) + assertResult( + result, + expected={"value": Eq({"_id": 1, "x": 1}), "ok": Eq(1.0)}, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_data_types.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_data_types.py new file mode 100644 index 000000000..c15c22e98 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_data_types.py @@ -0,0 +1,188 @@ +""" +Tests for findAndModify data type coverage: BSON types, null semantics, +numeric equivalence. +""" + +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, assertSuccessPartial +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 BSON_TYPE_SAMPLES, BsonType + +NULL_SEMANTICS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "query-null-matches-null-and-missing", + docs=[{"_id": 1, "f": None}, {"_id": 2, "f": 10}, {"_id": 3}], + command={ + "query": {"f": None}, + "update": {"$set": {"matched": True}}, + "sort": {"_id": 1}, + }, + expected={"value": Eq({"_id": 1, "f": None})}, + msg="query {f:null} matches docs where f is null AND where f is missing", + ), + CommandTestCase( + "set-null-distinct-from-unset", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": None}}, + "new": True, + }, + expected={"value": Eq({"_id": 1, "x": None})}, + msg="$set:{f:null} stores null (distinct from removing field)", + ), + CommandTestCase( + "query-false-does-not-match-zero", + docs=[{"_id": 1, "f": 0}, {"_id": 2, "f": False}], + command={ + "query": {"f": False}, + "update": {"$set": {"matched": True}}, + "sort": {"_id": 1}, + }, + expected={"value": Eq({"_id": 2, "f": False})}, + msg="query {f:false} does NOT match document with f:0", + ), + CommandTestCase( + "query-empty-string-does-not-match-null", + docs=[{"_id": 1, "f": None}, {"_id": 2, "f": ""}], + command={ + "query": {"f": ""}, + "update": {"$set": {"matched": True}}, + "sort": {"_id": 1}, + }, + expected={"value": Eq({"_id": 2, "f": ""})}, + msg='query {f:""} does NOT match document with f:null', + ), + CommandTestCase( + "decimal128-query-match-and-roundtrip", + docs=[{"_id": 1, "f": Decimal128("123.456")}], + command={ + "query": {"f": Decimal128("123.456")}, + "update": {"$set": {"matched": True}}, + }, + expected={"value": Eq({"_id": 1, "f": Decimal128("123.456")})}, + msg="Decimal128 query matches and preserves type", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(NULL_SEMANTICS_TESTS)) +def test_findAndModify_data_types(database_client, collection, test): + """Test findAndModify data type semantics.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + command = {"findAndModify": collection.name, **test.build_command(ctx)} + result = execute_command(collection, command) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + + +@pytest.mark.parametrize( + "stored,query_value", + [ + pytest.param(1, 1, id="int32_matches_1"), + pytest.param(1, Int64(1), id="int64_matches_1"), + pytest.param(1, 1.0, id="double_matches_1"), + pytest.param(1, Decimal128("1"), id="decimal_matches_1"), + pytest.param(0, 0, id="int32_matches_0"), + pytest.param(0, Int64(0), id="int64_matches_0"), + pytest.param(0, 0.0, id="double_matches_0"), + pytest.param(0, Decimal128("0"), id="decimal_matches_0"), + ], +) +def test_findAndModify_numeric_equivalence(collection, stored, query_value): + """Test numeric equivalence: different numeric types match the same stored value.""" + collection.insert_one({"_id": 1, "f": stored}) + result = execute_command( + collection, + { + "findAndModify": collection.name, + "query": {"f": query_value}, + "update": {"$set": {"matched": True}}, + }, + ) + assertSuccessPartial(result, {"value": {"_id": 1, "f": stored}, "lastErrorObject": {"n": 1}}) + + +BSON_TYPE_VALUES = [ + pytest.param(BSON_TYPE_SAMPLES[BsonType.DOUBLE], id="double"), + pytest.param(BSON_TYPE_SAMPLES[BsonType.STRING], id="string"), + pytest.param(BSON_TYPE_SAMPLES[BsonType.OBJECT], id="object"), + pytest.param(BSON_TYPE_SAMPLES[BsonType.ARRAY], id="array"), + pytest.param(Binary(b"\x00\x01\x02", 128), id="binary"), + pytest.param(BSON_TYPE_SAMPLES[BsonType.OBJECT_ID], id="objectid"), + pytest.param(BSON_TYPE_SAMPLES[BsonType.BOOL], id="bool"), + pytest.param(BSON_TYPE_SAMPLES[BsonType.DATE], id="date"), + pytest.param(BSON_TYPE_SAMPLES[BsonType.NULL], id="null"), + pytest.param(BSON_TYPE_SAMPLES[BsonType.REGEX], id="regex"), + pytest.param(BSON_TYPE_SAMPLES[BsonType.INT], id="int32"), + pytest.param(BSON_TYPE_SAMPLES[BsonType.TIMESTAMP], id="timestamp"), + pytest.param(BSON_TYPE_SAMPLES[BsonType.LONG], id="int64"), + pytest.param(BSON_TYPE_SAMPLES[BsonType.DECIMAL], id="decimal128"), + pytest.param(BSON_TYPE_SAMPLES[BsonType.MIN_KEY], id="minkey"), + pytest.param(BSON_TYPE_SAMPLES[BsonType.MAX_KEY], id="maxkey"), +] + + +@pytest.mark.parametrize("value", BSON_TYPE_VALUES) +def test_findAndModify_bson_type_query_match(collection, value): + """Test findAndModify query equality match works for each BSON type.""" + collection.insert_one({"_id": 1, "f": value}) + result = execute_command( + collection, + { + "findAndModify": collection.name, + "query": {"f": value}, + "update": {"$set": {"matched": True}}, + }, + ) + assertSuccessPartial(result, {"value": {"_id": 1, "f": value}, "lastErrorObject": {"n": 1}}) + + +@pytest.mark.parametrize("value", BSON_TYPE_VALUES) +def test_findAndModify_bson_type_set_roundtrip(collection, value): + """Test findAndModify $set stores BSON type and round-trips it.""" + collection.insert_one({"_id": 1}) + result = execute_command( + collection, + { + "findAndModify": collection.name, + "query": {"_id": 1}, + "update": {"$set": {"f": value}}, + "new": True, + }, + ) + assertSuccessPartial( + result, {"value": {"_id": 1, "f": value}, "lastErrorObject": {"updatedExisting": True}} + ) + + +@pytest.mark.parametrize("value", BSON_TYPE_VALUES) +def test_findAndModify_bson_type_replacement_roundtrip(collection, value): + """Test findAndModify replacement document preserves BSON type on round-trip.""" + collection.insert_one({"_id": 1, "f": "original"}) + result = execute_command( + collection, + { + "findAndModify": collection.name, + "query": {"_id": 1}, + "update": {"_id": 1, "f": value}, + "new": True, + }, + ) + assertSuccessPartial( + result, {"value": {"_id": 1, "f": value}, "lastErrorObject": {"updatedExisting": True}} + ) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_dollar_prefixed_fields.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_dollar_prefixed_fields.py new file mode 100644 index 000000000..102a71367 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_dollar_prefixed_fields.py @@ -0,0 +1,77 @@ +"""Tests for findAndModify with nested dollar-prefixed field names.""" + +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.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq + +DOLLAR_PREFIXED_SUCCESS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "set_nested_dollar_field_succeeds", + docs=[{"_id": 1, "a": {"$x": 1}}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"a.$x": 99}}, + "new": True, + }, + expected={"value": {"_id": Eq(1), "a": Eq({"$x": 99})}}, + msg="$set on nested dollar-prefixed field should succeed", + ), + CommandTestCase( + "inc_nested_dollar_field_succeeds", + docs=[{"_id": 1, "a": {"$count": 5}}], + command={ + "query": {"_id": 1}, + "update": {"$inc": {"a.$count": 1}}, + "new": True, + }, + expected={"value": {"_id": Eq(1), "a": Eq({"$count": 6})}}, + msg="$inc on nested dollar-prefixed field should succeed", + ), + CommandTestCase( + "mul_nested_dollar_field_succeeds", + docs=[{"_id": 1, "a": {"$val": 5}}], + command={ + "query": {"_id": 1}, + "update": {"$mul": {"a.$val": 2}}, + "new": True, + }, + expected={"value": {"_id": Eq(1), "a": Eq({"$val": 10})}}, + msg="$mul on nested dollar-prefixed field should succeed", + ), + CommandTestCase( + "max_nested_dollar_field_succeeds", + docs=[{"_id": 1, "a": {"$val": 5}}], + command={ + "query": {"_id": 1}, + "update": {"$max": {"a.$val": 10}}, + "new": True, + }, + expected={"value": {"_id": Eq(1), "a": Eq({"$val": 10})}}, + msg="$max on nested dollar-prefixed field should succeed", + ), +] + +ALL_TESTS = DOLLAR_PREFIXED_SUCCESS_TESTS + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_findAndModify_dollar_prefixed_fields(database_client, collection, test): + """Test findAndModify with dollar-prefixed and dotted field names.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + command = {"findAndModify": collection.name, **test.build_command(ctx)} + result = execute_command(collection, command) + 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/query_and_write/commands/findAndModify/test_findAndModify_errors.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_errors.py new file mode 100644 index 000000000..240ca31af --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_errors.py @@ -0,0 +1,514 @@ +""" +Tests for findAndModify error cases: argument conflicts, immutable field +violations, invalid projections, constraint violations, and arrayFilters errors. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, + IndexModel, +) +from documentdb_tests.framework.assertions import assertFailureCode, assertResult +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + DOCUMENT_VALIDATION_FAILURE_ERROR, + DOLLAR_PREFIXED_FIELD_NAME_ERROR, + DUPLICATE_KEY_ERROR, + FAILED_TO_PARSE_ERROR, + IMMUTABLE_FIELD_ERROR, + INVALID_OPTIONS_ERROR, + LET_UNDEFINED_VARIABLE_ERROR, + PROJECT_EXCLUSION_IN_INCLUSION_ERROR, + PROJECT_UNKNOWN_EXPRESSION_ERROR, + UNRECOGNIZED_COMMAND_FIELD_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +ARGUMENT_CONFLICT_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "neither-update-nor-remove", + docs=[{"_id": 1}], + command={ + "query": {"_id": 1}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="findAndModify with neither update nor remove should fail", + ), + CommandTestCase( + "both-remove-and-update", + docs=[{"_id": 1}], + command={ + "query": {"_id": 1}, + "remove": True, + "update": {"$set": {"x": 1}}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="findAndModify with both remove and update should fail", + ), + CommandTestCase( + "remove-and-new-true", + docs=[{"_id": 1}], + command={ + "query": {"_id": 1}, + "remove": True, + "new": True, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="findAndModify with remove:true and new:true should fail", + ), + CommandTestCase( + "remove-and-upsert", + docs=[{"_id": 1}], + command={ + "query": {"_id": 1}, + "remove": True, + "upsert": True, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="findAndModify with remove:true and upsert:true should fail", + ), + CommandTestCase( + "remove-and-update-with-sort", + docs=[{"_id": 1}], + command={ + "query": {"_id": 1}, + "remove": True, + "update": {"$set": {"x": 1}}, + "sort": {"_id": 1}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="findAndModify with both remove and update plus sort should fail", + ), + CommandTestCase( + "remove-update-and-upsert", + docs=[{"_id": 1}], + command={ + "query": {"_id": 1}, + "remove": True, + "update": {"$set": {"x": 1}}, + "upsert": True, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="findAndModify with remove, update and upsert all set should fail", + ), + CommandTestCase( + "unknown-field", + docs=[{"_id": 1}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 1}}, + "unknownField": True, + }, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="findAndModify with unrecognized top-level field should fail", + ), + CommandTestCase( + "mixed-dollar-and-plain-keys", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 20}, "y": 30}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="update mixing dollar-prefixed and plain keys should fail", + ), + CommandTestCase( + "pipeline-disallowed-stage", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": [{"$match": {"x": 10}}], + }, + error_code=INVALID_OPTIONS_ERROR, + msg="pipeline update with disallowed stage ($match) should fail", + ), +] + +BAD_VALUE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "hint-nonexistent-index", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 20}}, + "hint": "nonexistent_index", + }, + error_code=BAD_VALUE_ERROR, + msg="hint referencing non-existent index should fail with BadValue", + ), + CommandTestCase( + "maxTimeMS-negative", + docs=[{"_id": 1}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 1}}, + "maxTimeMS": -1, + }, + error_code=BAD_VALUE_ERROR, + msg="negative maxTimeMS should fail with BadValue", + ), +] + +PARSE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "maxTimeMS-fractional-double", + docs=[{"_id": 1}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 1}}, + "maxTimeMS": 100.5, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="maxTimeMS as fractional double should fail", + ), +] + +IMMUTABLE_FIELD_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "replacement-change-id", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": {"_id": 2, "x": 20}, + }, + error_code=IMMUTABLE_FIELD_ERROR, + msg="replacement attempting to change _id should fail", + ), + CommandTestCase( + "update-change-id-via-operator", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"_id": 2}}, + }, + error_code=IMMUTABLE_FIELD_ERROR, + msg="changing _id via $set operator should fail", + ), + CommandTestCase( + "setOnInsert-duplicate-id", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 2}, + "update": {"$setOnInsert": {"_id": 1}}, + "upsert": True, + }, + error_code=IMMUTABLE_FIELD_ERROR, + msg="upsert with $setOnInsert producing duplicate _id should fail", + ), + CommandTestCase( + "setOnInsert-id-mismatch", + docs=[], + command={ + "query": {"_id": 1}, + "update": {"$setOnInsert": {"_id": 2, "x": 10}}, + "upsert": True, + }, + error_code=IMMUTABLE_FIELD_ERROR, + msg="upsert with selector _id differing from $setOnInsert _id should fail", + ), + CommandTestCase( + "setOnInsert-document-id-different-values", + docs=[], + command={ + "query": {"_id": {"a": 1, "b": 2}}, + "update": {"$setOnInsert": {"_id": {"a": 1, "b": 99}, "x": 10}}, + "upsert": True, + }, + error_code=IMMUTABLE_FIELD_ERROR, + msg="upsert with document-typed _id having different field values should fail", + ), + CommandTestCase( + "setOnInsert-dotted-id-mismatch", + docs=[], + command={ + "query": {"_id.a": 1}, + "update": {"$setOnInsert": {"_id": {"a": 2}, "x": 10}}, + "upsert": True, + }, + error_code=IMMUTABLE_FIELD_ERROR, + msg="upsert with dotted id values differing should fail", + ), + CommandTestCase( + "setOnInsert-array-id-different-order", + docs=[], + command={ + "query": {"_id": [1, 2, 3]}, + "update": {"$setOnInsert": {"_id": [3, 2, 1], "x": 10}}, + "upsert": True, + }, + error_code=IMMUTABLE_FIELD_ERROR, + msg="upsert with array id having different element ordering should fail", + ), +] + +PROJECTION_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "projection-mixed-inclusion-exclusion", + docs=[{"_id": 1, "x": 10, "y": 20}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 99}}, + "fields": {"x": 1, "y": 0}, + }, + error_code=PROJECT_EXCLUSION_IN_INCLUSION_ERROR, + msg="fields projection mixing inclusion and exclusion should fail", + ), + CommandTestCase( + "projection-invalid-operator-no-flags", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 20}}, + "fields": {"x": {"$inc": 1}}, + }, + error_code=PROJECT_UNKNOWN_EXPRESSION_ERROR, + msg="update-style operator in projection should fail (no upsert/new flags)", + ), + CommandTestCase( + "projection-invalid-operator-upsert-only", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 20}}, + "upsert": True, + "fields": {"x": {"$inc": 1}}, + }, + error_code=PROJECT_UNKNOWN_EXPRESSION_ERROR, + msg="update-style operator in projection should fail (upsert only)", + ), + CommandTestCase( + "projection-invalid-operator-new-only", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 20}}, + "new": True, + "fields": {"x": {"$inc": 1}}, + }, + error_code=PROJECT_UNKNOWN_EXPRESSION_ERROR, + msg="update-style operator in projection should fail (new only)", + ), + CommandTestCase( + "projection-invalid-operator-upsert-and-new", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 20}}, + "upsert": True, + "new": True, + "fields": {"x": {"$inc": 1}}, + }, + error_code=PROJECT_UNKNOWN_EXPRESSION_ERROR, + msg="update-style operator in projection should fail (upsert + new)", + ), + CommandTestCase( + "projection-invalid-operator-insert-no-match", + docs=[], + command={ + "query": {"_id": 99}, + "update": {"$set": {"x": 20}}, + "upsert": True, + "new": True, + "fields": {"x": {"$inc": 1}}, + }, + error_code=PROJECT_UNKNOWN_EXPRESSION_ERROR, + msg="update-style operator in projection should fail (upsert insert path)", + ), +] + +DUPLICATE_KEY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "duplicate-key-violation", + docs=[{"_id": 1, "key": "a"}, {"_id": 2, "key": "b"}], + indexes=[IndexModel("key", unique=True)], + command={ + "query": {"_id": 2}, + "update": {"$set": {"key": "a"}}, + }, + error_code=DUPLICATE_KEY_ERROR, + msg="update causing unique index violation should fail with DuplicateKey", + ), +] + +ARGUMENT_ERROR_TESTS: list[CommandTestCase] = ( + ARGUMENT_CONFLICT_TESTS + + BAD_VALUE_TESTS + + PARSE_ERROR_TESTS + + IMMUTABLE_FIELD_TESTS + + PROJECTION_ERROR_TESTS + + DUPLICATE_KEY_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ARGUMENT_ERROR_TESTS)) +def test_findAndModify_argument_errors(database_client, collection, test): + """Test findAndModify argument validation errors.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + command = {"findAndModify": collection.name, **test.build_command(ctx)} + result = execute_command(collection, command) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + + +ARRAY_FILTERS_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "unreferenced_identifier_fails", + docs=[{"_id": 1, "grades": [85, 92]}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"grades.0": 100}}, + "arrayFilters": [{"elem": {"$gte": 90}}], + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="arrayFilters identifier not referenced in update should produce an error", + ), + CommandTestCase( + "identifier_no_corresponding_filter_fails", + docs=[{"_id": 1, "grades": [85, 92]}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"grades.$[elem]": 100}}, + }, + error_code=BAD_VALUE_ERROR, + msg="update with $[identifier] but no matching arrayFilters entry should produce an error", + ), + CommandTestCase( + "duplicate_identifier_fails", + docs=[{"_id": 1, "grades": [85, 92]}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"grades.$[elem]": 100}}, + "arrayFilters": [{"elem": {"$gte": 90}}, {"elem": {"$lte": 80}}], + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="two arrayFilters for the same identifier should produce an error", + ), + CommandTestCase( + "with_pipeline_update_fails", + docs=[{"_id": 1, "grades": [85, 92]}], + command={ + "query": {"_id": 1}, + "update": [{"$set": {"modified": True}}], + "arrayFilters": [{"elem": {"$gte": 90}}], + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="arrayFilters with aggregation-pipeline update should produce an error", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(ARRAY_FILTERS_ERROR_TESTS)) +def test_findAndModify_array_filters_errors(database_client, collection, test): + """Test findAndModify arrayFilters validation errors.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + command = {"findAndModify": collection.name, **test.build_command(ctx)} + result = execute_command(collection, command) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + + +DOLLAR_PREFIXED_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "set_top_level_dollar_field_fails", + docs=[{"_id": 1}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"$bad": 1}}, + }, + error_code=DOLLAR_PREFIXED_FIELD_NAME_ERROR, + msg="$set on top-level dollar-prefixed field should fail", + ), + CommandTestCase( + "inc_top_level_dollar_field_fails", + docs=[{"_id": 1, "$val": 10}], + command={ + "query": {"_id": 1}, + "update": {"$inc": {"$val": 1}}, + }, + error_code=DOLLAR_PREFIXED_FIELD_NAME_ERROR, + msg="$inc on top-level dollar-prefixed field should fail", + ), + CommandTestCase( + "mul_top_level_dollar_field_fails", + docs=[{"_id": 1, "$val": 10}], + command={ + "query": {"_id": 1}, + "update": {"$mul": {"$val": 2}}, + }, + error_code=DOLLAR_PREFIXED_FIELD_NAME_ERROR, + msg="$mul on top-level dollar-prefixed field should fail", + ), + CommandTestCase( + "max_top_level_dollar_field_fails", + docs=[{"_id": 1, "$val": 10}], + command={ + "query": {"_id": 1}, + "update": {"$max": {"$val": 20}}, + }, + error_code=DOLLAR_PREFIXED_FIELD_NAME_ERROR, + msg="$max on top-level dollar-prefixed field should fail", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(DOLLAR_PREFIXED_ERROR_TESTS)) +def test_findAndModify_dollar_prefixed_errors(database_client, collection, test): + """Test findAndModify dollar-prefixed field validation errors.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + command = {"findAndModify": collection.name, **test.build_command(ctx)} + result = execute_command(collection, command) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + + +def test_findAndModify_validation_rejects_invalid_write(database_client, request): + """Test bypassDocumentValidation:false rejects non-conforming update.""" + db = database_client + coll_name = f"{request.node.name}_validated" + db.create_collection(coll_name, validator={"$jsonSchema": {"required": ["name"]}}) + coll = db[coll_name] + coll.insert_one({"_id": 1, "name": "test"}) + result = execute_command( + coll, + { + "findAndModify": coll.name, + "query": {"_id": 1}, + "update": {"$unset": {"name": ""}}, + }, + ) + assertFailureCode(result, DOCUMENT_VALIDATION_FAILURE_ERROR) + + +def test_findAndModify_expr_undefined_variable_fails(collection): + """Test $expr referencing undefined variable produces an error.""" + collection.insert_one({"_id": 1, "a": 10}) + result = execute_command( + collection, + { + "findAndModify": collection.name, + "query": {"$expr": {"$eq": ["$a", "$$undefined_var"]}}, + "update": {"$set": {"x": 1}}, + }, + ) + assertFailureCode(result, LET_UNDEFINED_VARIABLE_ERROR) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_expr_let.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_expr_let.py new file mode 100644 index 000000000..d0df1fbba --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_expr_let.py @@ -0,0 +1,116 @@ +"""Tests for findAndModify with $expr in query and let variables.""" + +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.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq + +EXPR_LET_SUCCESS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "expr_comparison_selects_correct_doc", + docs=[ + {"_id": 1, "a": 5, "b": 3}, + {"_id": 2, "a": 1, "b": 10}, + ], + command={ + "query": {"$expr": {"$gt": ["$a", "$b"]}}, + "update": {"$set": {"matched": True}}, + "sort": {"_id": 1}, + }, + expected={"value": {"_id": Eq(1), "a": Eq(5), "b": Eq(3)}}, + msg="findAndModify query using $expr with $gt should select correct document", + ), + CommandTestCase( + "expr_with_let_variable", + docs=[ + {"_id": 1, "a": 10}, + {"_id": 2, "a": 20}, + ], + command={ + "query": {"$expr": {"$eq": ["$a", "$$target"]}}, + "update": {"$set": {"found": True}}, + "let": {"target": 20}, + }, + expected={"value": {"_id": Eq(2), "a": Eq(20)}}, + msg="$expr referencing a let variable should select correct document", + ), + CommandTestCase( + "let_variable_in_pipeline_update", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": [{"$set": {"y": "$$bonus"}}], + "let": {"bonus": 100}, + "new": True, + }, + expected={"value": {"_id": Eq(1), "x": Eq(10), "y": Eq(100)}}, + msg="findAndModify with let variable used in update pipeline should apply variable value", + ), + CommandTestCase( + "expr_with_sort_composes_correctly", + docs=[ + {"_id": 1, "a": 5, "b": 3}, + {"_id": 2, "a": 8, "b": 2}, + {"_id": 3, "a": 1, "b": 10}, + ], + command={ + "query": {"$expr": {"$gt": ["$a", "$b"]}}, + "update": {"$set": {"matched": True}}, + "sort": {"_id": -1}, + }, + # Both _id:1 (a=5>b=3) and _id:2 (a=8>b=2) match; sort desc picks _id:2 + expected={"value": {"_id": Eq(2), "a": Eq(8), "b": Eq(2)}}, + msg="$expr filtering combined with descending sort picks highest _id among matches", + ), + CommandTestCase( + "let_in_replacement_update", + docs=[{"_id": 1, "a": 5}, {"_id": 2, "a": 10}], + command={ + "query": {"$expr": {"$eq": ["$a", "$$val"]}}, + "update": {"a": 99, "replaced": True}, + "let": {"val": 10}, + "new": True, + }, + expected={"value": {"_id": Eq(2), "a": Eq(99), "replaced": Eq(True)}}, + msg="let variable in $expr query with replacement update should select and replace", + ), + CommandTestCase( + "expr_literal_true_matches_all", + docs=[ + {"_id": 1, "a": 5, "b": 3}, + {"_id": 2, "a": 1, "b": 10}, + {"_id": 3, "a": -1, "b": 0}, + ], + command={ + "query": {"$expr": True}, + "update": {"$set": {"touched": True}}, + "sort": {"_id": 1}, + }, + expected={"value": {"_id": Eq(1), "a": Eq(5), "b": Eq(3)}}, + msg="$expr with literal true should match all and return first by sort", + ), +] + +ALL_TESTS = EXPR_LET_SUCCESS_TESTS + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_findAndModify_expr_let(database_client, collection, test): + """Test findAndModify $expr in query and let variables.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + command = {"findAndModify": collection.name, **test.build_command(ctx)} + result = execute_command(collection, command) + 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/query_and_write/commands/findAndModify/test_findAndModify_projection.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_projection.py new file mode 100644 index 000000000..e6e2e0324 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_projection.py @@ -0,0 +1,170 @@ +""" +Tests for findAndModify fields projection behavior. +""" + +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.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq + +UPDATE_PROJECTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "new-true-returns-post-image-projection", + docs=[{"_id": 1, "x": 10, "y": 20}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 99}}, + "fields": {"x": 1}, + "new": True, + }, + expected={"value": Eq({"_id": 1, "x": 99})}, + msg="fields projection with new:true returns projection of post-modification doc", + ), + CommandTestCase( + "new-false-returns-pre-image-projection", + docs=[{"_id": 1, "x": 10, "y": 20}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 99}}, + "fields": {"x": 1}, + "new": False, + }, + expected={"value": Eq({"_id": 1, "x": 10})}, + msg="fields projection with new:false returns projection of pre-modification doc", + ), + CommandTestCase( + "exclude-id-from-projection", + docs=[{"_id": 1, "x": 10, "y": 20}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 99}}, + "fields": {"_id": 0, "x": 1}, + }, + expected={"value": Eq({"x": 10})}, + msg="fields projection with _id:0 omits _id from returned value", + ), +] + +REMOVE_PROJECTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "remove-inclusion-projection", + docs=[{"_id": 1, "x": 10, "y": 20, "z": 30}], + command={ + "query": {"_id": 1}, + "remove": True, + "fields": {"x": 1}, + }, + expected={"value": Eq({"_id": 1, "x": 10})}, + msg="remove with inclusion projection returns _id plus included field", + ), + CommandTestCase( + "remove-exclusion-projection", + docs=[{"_id": 1, "x": 10, "y": 20}], + command={ + "query": {"_id": 1}, + "remove": True, + "fields": {"y": 0}, + }, + expected={"value": Eq({"_id": 1, "x": 10})}, + msg="remove with exclusion projection returns document without excluded field", + ), + CommandTestCase( + "remove-exclude-id-and-field", + docs=[{"_id": 1, "x": 10, "y": 20}], + command={ + "query": {"_id": 1}, + "remove": True, + "fields": {"_id": 0, "x": 0}, + }, + expected={"value": Eq({"y": 20})}, + msg="remove with exclusion of both _id and a field", + ), + CommandTestCase( + "remove-combined-query-sort-projection", + docs=[ + {"_id": 1, "x": 5, "y": "a"}, + {"_id": 2, "x": 15, "y": "b"}, + {"_id": 3, "x": 25, "y": "c"}, + ], + command={ + "query": {"x": {"$gt": 10}}, + "remove": True, + "sort": {"x": -1}, + "fields": {"y": 0}, + }, + expected={"value": Eq({"_id": 3, "x": 25})}, + msg="remove combining range query, exclusion projection, and descending sort", + ), +] + +UPSERT_PROJECTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "projection-on-upsert-new-true", + docs=[], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 10, "y": 20}}, + "upsert": True, + "new": True, + "fields": {"x": 1}, + }, + expected={"value": Eq({"_id": 1, "x": 10})}, + msg="projection on upserted document returns only projected fields", + ), +] + +COMPUTED_PROJECTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "computed-expression-new-true", + docs=[{"_id": 1, "x": 5}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 10}}, + "fields": {"doubled": {"$multiply": ["$x", 2]}, "_id": 0}, + "new": True, + }, + expected={"value": Eq({"doubled": 20})}, + msg="computed expression in fields evaluates against post-image when new:true", + ), + CommandTestCase( + "computed-expression-new-false", + docs=[{"_id": 1, "x": 5}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 10}}, + "fields": {"doubled": {"$multiply": ["$x", 2]}, "_id": 0}, + "new": False, + }, + expected={"value": Eq({"doubled": 10})}, + msg="computed expression in fields evaluates against pre-image when new:false", + ), +] + +ALL_TESTS: list[CommandTestCase] = ( + UPDATE_PROJECTION_TESTS + + REMOVE_PROJECTION_TESTS + + UPSERT_PROJECTION_TESTS + + COMPUTED_PROJECTION_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_findAndModify_projection(database_client, collection, test): + """Test findAndModify fields projection behavior.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + command = {"findAndModify": collection.name, **test.build_command(ctx)} + result = execute_command(collection, command) + 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/query_and_write/commands/findAndModify/test_findAndModify_update_modes.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_update_modes.py new file mode 100644 index 000000000..2888b40c6 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_update_modes.py @@ -0,0 +1,289 @@ +""" +Tests for findAndModify update modes: operator updates, replacements, pipelines, +arrayFilters, boundary inputs, and input interactions. +""" + +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_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq + +UPDATE_MODE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "all-dollar-keys-is-update-operator", + docs=[{"_id": 1, "x": 10, "y": 5}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 20}, "$inc": {"y": 1}}, + "new": True, + }, + expected={"value": Eq({"_id": 1, "x": 20, "y": 6})}, + msg="update with all dollar-prefixed keys treated as update-operator form", + ), + CommandTestCase( + "no-dollar-keys-is-replacement", + docs=[{"_id": 1, "x": 10, "y": 20}], + command={ + "query": {"_id": 1}, + "update": {"z": 30}, + "new": True, + }, + expected={"value": Eq({"_id": 1, "z": 30})}, + msg="update with no dollar-prefixed keys treated as replacement", + ), + CommandTestCase( + "pipeline-references-existing-fields", + docs=[{"_id": 1, "a": 3, "b": 7}], + command={ + "query": {"_id": 1}, + "update": [{"$set": {"total": {"$add": ["$a", "$b"]}}}], + "new": True, + }, + expected={"value": Eq({"_id": 1, "a": 3, "b": 7, "total": 10})}, + msg="pipeline update can reference existing field values", + ), +] + +BOUNDARY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "empty-set-is-noop", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": {"$set": {}}, + "new": True, + }, + expected={ + "value": Eq({"_id": 1, "x": 10}), + "lastErrorObject": Eq({"n": 1, "updatedExisting": True}), + }, + msg="update {$set:{}} succeeds with no field change", + ), + CommandTestCase( + "empty-replacement", + docs=[{"_id": 1, "x": 10, "y": 20}], + command={ + "query": {"_id": 1}, + "update": {}, + "new": True, + }, + expected={ + "lastErrorObject": Eq({"n": 1, "updatedExisting": True}), + "value": Eq({"_id": 1}), + "ok": Eq(1.0), + }, + msg="replacement with empty document {} leaves only _id", + ), + CommandTestCase( + "unset-removes-field", + docs=[{"_id": 1, "x": 10, "y": 20}], + command={ + "query": {"_id": 1}, + "update": {"$unset": {"y": ""}}, + "new": True, + }, + expected={ + "lastErrorObject": Eq({"n": 1, "updatedExisting": True}), + "value": Eq({"_id": 1, "x": 10}), + "ok": Eq(1.0), + }, + msg="$unset removes field and new:true doc omits it", + ), + CommandTestCase( + "deeply-nested-set-creates-intermediates", + docs=[{"_id": 1}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"a.b.c.d.e": 1}}, + "new": True, + }, + expected={"value": Eq({"_id": 1, "a": {"b": {"c": {"d": {"e": 1}}}}})}, + msg="$set with deeply nested path creates intermediate documents", + ), +] + +DOTTED_PATH_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "set-dotted-index-on-array", + docs=[{"_id": 1, "a": [10, 20, 30]}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"a.0": 99}}, + "new": True, + }, + expected={"value": Eq({"_id": 1, "a": [99, 20, 30]})}, + msg="$set with dotted numeric index on array updates the element at that index", + ), + CommandTestCase( + "set-dotted-index-on-object", + docs=[{"_id": 1, "a": {"0": "old", "1": "keep"}}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"a.0": "new"}}, + "new": True, + }, + expected={"value": Eq({"_id": 1, "a": {"0": "new", "1": "keep"}})}, + msg="$set with dotted numeric key on object updates the field named '0'", + ), +] + +INTERACTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "upsert-with-sort-no-match-still-inserts", + docs=[], + command={ + "query": {"_id": 1}, + "sort": {"x": -1}, + "update": {"$set": {"x": 10}}, + "upsert": True, + "new": True, + }, + expected={"value": Eq({"_id": 1, "x": 10})}, + msg="upsert:true + sort: when no match, insert still occurs", + ), + CommandTestCase( + "projection-on-removed-field-new-true", + docs=[{"_id": 1, "x": 10, "y": 20}], + command={ + "query": {"_id": 1}, + "update": {"$unset": {"x": ""}}, + "fields": {"x": 1}, + "new": True, + }, + expected={"value": Eq({"_id": 1})}, + msg="fields projection on a field removed by update (new:true) -- absent", + ), + CommandTestCase( + "atomic-pre-image-consistent", + docs=[{"_id": 1, "x": 10, "y": 20}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 99, "y": 99}}, + }, + expected={"value": Eq({"_id": 1, "x": 10, "y": 20})}, + msg="findAndModify pre-image is self-consistent", + ), +] + +ALL_TESTS: list[CommandTestCase] = ( + UPDATE_MODE_TESTS + BOUNDARY_TESTS + DOTTED_PATH_TESTS + INTERACTION_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_findAndModify_update_modes(database_client, collection, test): + """Test findAndModify update modes.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + command = {"findAndModify": collection.name, **test.build_command(ctx)} + result = execute_command(collection, command) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + + +def test_findAndModify_array_filters_updates_matching_elements(collection): + """Test arrayFilters restricts update to matching array elements.""" + collection.insert_one({"_id": 1, "grades": [85, 92, 78, 95]}) + result = execute_command( + collection, + { + "findAndModify": collection.name, + "query": {"_id": 1}, + "update": {"$set": {"grades.$[elem]": 100}}, + "arrayFilters": [{"elem": {"$gte": 90}}], + "new": True, + }, + ) + assertSuccessPartial(result, {"value": {"_id": 1, "grades": [85, 100, 78, 100]}}) + + +def test_findAndModify_array_filters_multiple_identifiers(collection): + """Test arrayFilters with multiple identifiers in same update.""" + collection.insert_one({"_id": 1, "scores": [5, 15, 25, 35]}) + result = execute_command( + collection, + { + "findAndModify": collection.name, + "query": {"_id": 1}, + "update": { + "$set": {"scores.$[low]": 0, "scores.$[high]": 100}, + }, + "arrayFilters": [{"low": {"$lt": 10}}, {"high": {"$gt": 30}}], + "new": True, + }, + ) + assertSuccessPartial(result, {"value": {"_id": 1, "scores": [0, 15, 25, 100]}}) + + +def test_findAndModify_positional_operator(collection): + """Test positional $ operator updates first matching array element.""" + collection.insert_one({"_id": 1, "items": [10, 20, 30]}) + result = execute_command( + collection, + { + "findAndModify": collection.name, + "query": {"_id": 1, "items": 20}, + "update": {"$set": {"items.$": 99}}, + "new": True, + }, + ) + assertSuccessPartial(result, {"value": {"_id": 1, "items": [10, 99, 30]}}) + + +def test_findAndModify_pipeline_multiple_stages(collection): + """Test pipeline update with multiple stages.""" + collection.insert_one({"_id": 1, "x": 10, "y": 20, "tmp": "remove_me"}) + result = execute_command( + collection, + { + "findAndModify": collection.name, + "query": {"_id": 1}, + "update": [ + {"$set": {"sum": {"$add": ["$x", "$y"]}}}, + {"$unset": "tmp"}, + ], + "new": True, + }, + ) + assertSuccessPartial(result, {"value": {"_id": 1, "x": 10, "y": 20, "sum": 30}}) + + +def test_findAndModify_pull(collection): + """Test $pull with findAndModify removes matching elements and returns updated doc.""" + collection.insert_one({"_id": 1, "arr": [1, 2, 3, 2]}) + result = execute_command( + collection, + { + "findAndModify": collection.name, + "query": {"_id": 1}, + "update": {"$pull": {"arr": 2}}, + "new": True, + }, + ) + assertSuccessPartial(result, {"value": {"_id": 1, "arr": [1, 3]}}) + + +def test_findAndModify_addToSet(collection): + """Test $addToSet with findAndModify adds element and returns updated doc.""" + collection.insert_one({"_id": 1, "arr": ["a"]}) + result = execute_command( + collection, + { + "findAndModify": collection.name, + "query": {"_id": 1}, + "update": {"$addToSet": {"arr": "b"}}, + "new": True, + }, + ) + assertSuccessPartial(result, {"value": {"_id": 1, "arr": ["a", "b"]}}) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_upsert.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_upsert.py new file mode 100644 index 000000000..b6f799dd8 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_upsert.py @@ -0,0 +1,461 @@ +""" +Tests for findAndModify upsert behavior: lastErrorObject, $setOnInsert, +id consistency, auto-increment patterns. +""" + +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 IMMUTABLE_FIELD_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, IsType, NotExists + +EQUALITY_SEEDING_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "equality-query-seeds-field", + docs=[], + command={ + "query": {"x": 10}, + "update": {"$set": {"y": 20}}, + "upsert": True, + "new": True, + }, + expected={"value": {"_id": Exists(), "x": Eq(10), "y": Eq(20)}}, + msg="upsert with equality predicate seeds field value into inserted doc", + ), + CommandTestCase( + "non-equality-does-not-seed", + docs=[], + command={ + "query": {"x": {"$gt": 5}}, + "update": {"$set": {"y": 20}}, + "upsert": True, + "new": True, + }, + expected={"value": {"_id": Exists(), "x": NotExists(), "y": Eq(20)}}, + msg="upsert with non-equality operator ($gt) does NOT seed field", + ), + CommandTestCase( + "no-id-auto-creates-id", + docs=[], + command={ + "query": {"x": 10}, + "update": {"$set": {"x": 10, "y": 20}}, + "upsert": True, + "new": True, + }, + expected={"value": {"_id": IsType("objectId"), "x": Eq(10), "y": Eq(20)}}, + msg="upsert with no _id in query/update auto-creates an ObjectId _id", + ), +] + +REPLACEMENT_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "replacement-inserts-replacement", + docs=[], + command={ + "query": {"_id": 1}, + "update": {"_id": 1, "z": 99}, + "upsert": True, + "new": True, + }, + expected={"value": Eq({"_id": 1, "z": 99})}, + msg="upsert with replacement update inserts the replacement document", + ), +] + +LAST_ERROR_OBJECT_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "lastErrorObject-upserted-id", + docs=[], + command={ + "query": {"_id": 42}, + "update": {"$set": {"x": 1}}, + "upsert": True, + }, + expected={ + "lastErrorObject": Eq({"n": 1, "updatedExisting": False, "upserted": 42}), + }, + msg="lastErrorObject.upserted is the _id of the new document", + ), + CommandTestCase( + "new-false-returns-null-value-key", + docs=[], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 10}}, + "upsert": True, + "new": False, + }, + expected={ + "value": Eq(None), + "lastErrorObject": Eq({"n": 1, "updatedExisting": False, "upserted": 1}), + }, + msg="upsert insert with new=false returns value key set to null", + ), + CommandTestCase( + "upsert-false-nonexistent-returns-null", + docs=[], + command={ + "query": {"_id": 999}, + "update": {"$set": {"x": 10}}, + "upsert": False, + }, + expected={ + "lastErrorObject": Eq({"n": 0, "updatedExisting": False}), + "value": Eq(None), + "ok": Eq(1.0), + }, + msg="upsert=false on non-existent document returns null and inserts nothing", + ), +] + +SET_ON_INSERT_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "setOnInsert-sets-id-on-upsert", + docs=[], + command={ + "query": {"_id": 1}, + "update": {"$setOnInsert": {"x": 10}}, + "upsert": True, + "new": True, + }, + expected={"value": Eq({"_id": 1, "x": 10})}, + msg="upsert with $setOnInsert inserts new document using _id from query", + ), + CommandTestCase( + "setOnInsert-noop-when-match-exists", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": {"$setOnInsert": {"x": 99}}, + "upsert": True, + "new": True, + }, + expected={"value": Eq({"_id": 1, "x": 10})}, + msg="$setOnInsert is a no-op when matching doc already exists", + ), + CommandTestCase( + "setOnInsert-document-id-match-succeeds", + docs=[], + command={ + "query": {"_id": {"a": 1, "b": 2}}, + "update": {"$setOnInsert": {"_id": {"a": 1, "b": 2}, "x": 10}}, + "upsert": True, + "new": True, + }, + expected={"value": Eq({"_id": {"a": 1, "b": 2}, "x": 10})}, + msg="upsert succeeds when selector and $setOnInsert specify identical document ids", + ), + CommandTestCase( + "setOnInsert-dotted-id-match-succeeds", + docs=[], + command={ + "query": {"_id.a": 1}, + "update": {"$setOnInsert": {"x": 10}}, + "upsert": True, + "new": True, + }, + expected={"value": Eq({"_id": {"a": 1}, "x": 10})}, + msg="upsert with dotted _id query constructs _id from dotted notation", + ), + CommandTestCase( + "setOnInsert-selector-has-id-setOnInsert-omits", + docs=[], + command={ + "query": {"_id": 1}, + "update": {"$setOnInsert": {"x": 10}}, + "upsert": True, + "new": True, + }, + expected={"value": Eq({"_id": 1, "x": 10})}, + msg="upsert succeeds when selector specifies _id and $setOnInsert omits it", + ), + CommandTestCase( + "setOnInsert-omits-id-sets-unique", + docs=[], + command={ + "query": {"x": 999}, + "update": {"$setOnInsert": {"_id": 42, "y": 1}}, + "upsert": True, + "new": True, + }, + expected={"value": Eq({"_id": 42, "x": 999, "y": 1})}, + msg="upsert succeeds when selector omits _id and $setOnInsert sets a unique _id", + ), + CommandTestCase( + "setOnInsert-range-filter-no-match-inserts", + docs=[], + command={ + "query": {"_id": {"$gt": 100}}, + "update": {"$setOnInsert": {"x": 10}}, + "upsert": True, + "new": True, + }, + expected={"value": {"_id": IsType("objectId"), "x": Eq(10)}}, + msg="upsert with range filter on _id matching no docs inserts using $setOnInsert", + ), + CommandTestCase( + "setOnInsert-range-filter-match-updates", + docs=[{"_id": 200, "x": 5}], + command={ + "query": {"_id": {"$gt": 100}}, + "update": {"$setOnInsert": {"x": 99}}, + "upsert": True, + "new": True, + }, + expected={"value": Eq({"_id": 200, "x": 5})}, + msg="upsert with range filter matching existing doc is noop for $setOnInsert", + ), + CommandTestCase( + "setOnInsert-subset-id-subfields-consistent", + docs=[], + command={ + "query": {"_id": {"a": 1, "b": 2}}, + "update": {"$setOnInsert": {"_id": {"a": 1, "b": 2}, "x": 10}}, + "upsert": True, + "new": True, + }, + expected={"value": Eq({"_id": {"a": 1, "b": 2}, "x": 10})}, + msg="upsert with $setOnInsert _id sub-fields consistent with selector succeeds", + ), + CommandTestCase( + "setOnInsert-id-subfields-different-order", + docs=[], + command={ + "query": {"_id": {"a": 1, "b": 2}}, + "update": {"$setOnInsert": {"_id": {"b": 2, "a": 1}, "x": 10}}, + "upsert": True, + }, + error_code=IMMUTABLE_FIELD_ERROR, + msg="upsert with $setOnInsert _id sub-fields in different order fails", + ), +] + +AUTO_INCREMENT_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "auto-increment-pattern", + docs=[], + command={ + "query": {"_id": "counter"}, + "update": {"$inc": {"val": 1}}, + "upsert": True, + "new": True, + }, + expected={"value": Eq({"_id": "counter", "val": 1})}, + msg="upsert with $inc builds auto-incrementing counter", + ), +] + +PIPELINE_UPSERT_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "pipeline-upsert-no-match-seeds-from-equality-query", + docs=[], + command={ + "query": {"_id": 1, "base": 10}, + "update": [{"$set": {"doubled": {"$multiply": ["$base", 2]}}}], + "upsert": True, + "new": True, + }, + expected={ + "value": Eq({"_id": 1, "base": 10, "doubled": 20}), + "lastErrorObject": Eq({"n": 1, "updatedExisting": False, "upserted": 1}), + }, + msg="pipeline upsert with no match: pipeline computes against the " + "equality-query-seeded document", + ), + CommandTestCase( + "pipeline-upsert-no-match-non-equality-field-absent-during-compute", + docs=[], + command={ + "query": {"_id": 1, "base": {"$gt": 5}}, + "update": [{"$set": {"doubled": {"$multiply": [{"$ifNull": ["$base", 0]}, 2]}}}], + "upsert": True, + "new": True, + }, + expected={ + "value": Eq({"_id": 1, "doubled": 0}), + "lastErrorObject": Eq({"n": 1, "updatedExisting": False, "upserted": 1}), + }, + msg="pipeline upsert with no match: non-equality ($gt) query field is NOT " + "seeded, so the pipeline sees it as missing", + ), + CommandTestCase( + "pipeline-upsert-existing-match-computes-from-stored-doc", + docs=[{"_id": 1, "base": 7}], + command={ + "query": {"_id": 1}, + "update": [{"$set": {"doubled": {"$multiply": ["$base", 2]}}}], + "upsert": True, + "new": True, + }, + expected={ + "value": Eq({"_id": 1, "base": 7, "doubled": 14}), + "lastErrorObject": Eq({"n": 1, "updatedExisting": True}), + }, + msg="pipeline upsert with existing match: pipeline computes against the " "stored document", + ), +] + +UPSERT_PROJECTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "upsert-new-true-id-exclusion-projection", + docs=[], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 10, "y": 20}}, + "upsert": True, + "new": True, + "fields": {"_id": 0, "x": 1}, + }, + expected={"value": Eq({"x": 10})}, + msg="upsert + new:true + id-exclusion projection returns doc without _id", + ), + CommandTestCase( + "upsert-new-false-excludes-all-returns-null-value", + docs=[], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 10}}, + "upsert": True, + "new": False, + "fields": {"x": 1}, + }, + expected={ + "value": Eq(None), + "lastErrorObject": Eq({"n": 1, "updatedExisting": False, "upserted": 1}), + }, + msg="upsert + new:false returns null even with projection (no pre-image)", + ), + CommandTestCase( + "upsert-replacement-new-true-projection", + docs=[], + command={ + "query": {"_id": 1}, + "update": {"_id": 1, "a": 1, "b": 2}, + "upsert": True, + "new": True, + "fields": {"a": 1}, + }, + expected={"value": Eq({"_id": 1, "a": 1})}, + msg="upsert + replacement + new:true + projection returns projected post-image", + ), + CommandTestCase( + "upsert-replacement-new-false", + docs=[], + command={ + "query": {"_id": 1}, + "update": {"_id": 1, "a": 1, "b": 2}, + "upsert": True, + "new": False, + }, + expected={ + "value": Eq(None), + "lastErrorObject": Eq({"n": 1, "updatedExisting": False, "upserted": 1}), + }, + msg="upsert + replacement + new:false returns null (pre-image of insert is nothing)", + ), +] + +ALL_TESTS: list[CommandTestCase] = ( + EQUALITY_SEEDING_TESTS + + REPLACEMENT_TESTS + + LAST_ERROR_OBJECT_TESTS + + SET_ON_INSERT_TESTS + + AUTO_INCREMENT_TESTS + + PIPELINE_UPSERT_TESTS + + UPSERT_PROJECTION_TESTS + + [ + CommandTestCase( + "upsert-updates-existing-doc", + docs=[{"_id": 1, "x": 10, "y": 5}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 99}}, + "upsert": True, + "new": True, + }, + expected={ + "value": Eq({"_id": 1, "x": 99, "y": 5}), + "lastErrorObject": Eq({"n": 1, "updatedExisting": True}), + }, + msg="upsert with existing match updates doc instead of inserting", + ), + CommandTestCase( + "setOnInsert-combined-with-set", + docs=[], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 10}, "$setOnInsert": {"y": 99}}, + "upsert": True, + "new": True, + }, + expected={"value": Eq({"_id": 1, "x": 10, "y": 99})}, + msg="upsert with $set + $setOnInsert applies both on insert", + ), + ] +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_findAndModify_upsert(database_client, collection, test): + """Test findAndModify upsert behavior.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + command = {"findAndModify": collection.name, **test.build_command(ctx)} + result = execute_command(collection, command) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + + +def test_findAndModify_upsert_unique_index_updates_instead_of_dup(collection): + """Test upsert with unique index: second upsert updates rather than duplicates.""" + collection.create_index("key", unique=True) + execute_command( + collection, + { + "findAndModify": collection.name, + "query": {"key": "abc"}, + "update": {"$set": {"val": 1}}, + "upsert": True, + }, + ) + result = execute_command( + collection, + { + "findAndModify": collection.name, + "query": {"key": "abc"}, + "update": {"$set": {"val": 2}}, + "upsert": True, + "new": True, + }, + ) + assertSuccessPartial( + result, {"value": {"key": "abc", "val": 2}, "lastErrorObject": {"updatedExisting": True}} + ) + + +def test_findAndModify_upsert_array_filters_no_matching_array(collection): + """Test upsert + arrayFilters when inserted doc has no matching array elements.""" + collection.insert_one({"_id": 1, "grades": [50, 60, 70]}) + result = execute_command( + collection, + { + "findAndModify": collection.name, + "query": {"_id": 1}, + "update": {"$set": {"grades.$[elem]": 100}}, + "arrayFilters": [{"elem": {"$gte": 90}}], + "new": True, + }, + ) + assertSuccessPartial(result, {"value": {"_id": 1, "grades": [50, 60, 70]}}) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_with_expr.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_with_expr.py deleted file mode 100644 index 261b480f1..000000000 --- a/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_with_expr.py +++ /dev/null @@ -1,46 +0,0 @@ -""" -Tests for $expr in findAndModify command contexts. -""" - -from documentdb_tests.framework.assertions import assertSuccess -from documentdb_tests.framework.executor import execute_command - -BASIC_DOCS = [ - {"_id": 1, "a": 5, "b": 3}, - {"_id": 2, "a": 1, "b": 10}, - {"_id": 3, "a": -1, "b": 0}, -] - - -def test_expr_in_find_and_modify(collection): - """Test $expr in findAndModify query.""" - collection.insert_many(BASIC_DOCS) - result = execute_command( - collection, - { - "findAndModify": collection.name, - "query": {"$expr": {"$gt": ["$a", "$b"]}}, - "update": {"$set": {"modified": True}}, - "sort": {"_id": 1}, - }, - ) - assertSuccess( - result, {"_id": 1, "a": 5, "b": 3}, raw_res=True, transform=lambda r: r.get("value") - ) - - -def test_expr_findandmodify_literal_true(collection): - """Test $expr with literal true in findAndModify — matches all, returns first by sort.""" - collection.insert_many(BASIC_DOCS) - result = execute_command( - collection, - { - "findAndModify": collection.name, - "query": {"$expr": True}, - "update": {"$set": {"touched": True}}, - "sort": {"_id": 1}, - }, - ) - assertSuccess( - result, {"_id": 1, "a": 5, "b": 3}, raw_res=True, transform=lambda r: r.get("value") - ) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_bson_type_validation.py b/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_bson_type_validation.py new file mode 100644 index 000000000..32a2f97f7 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_bson_type_validation.py @@ -0,0 +1,82 @@ +"""readConcern BSON type validation: field must be a document, level must be a string.""" + +import pytest + +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 TYPE_MISMATCH_ERROR +from documentdb_tests.framework.executor import execute_command + +READ_CONCERN_PARAMS = [ + BsonTypeTestCase( + id="read_concern_field", + msg="readConcern field should reject non-document BSON types", + valid_types=[BsonType.OBJECT], + skip_rejection_types=[BsonType.NULL], + default_error_code=TYPE_MISMATCH_ERROR, + expected=[{"_id": 1, "x": 1}], + valid_inputs={BsonType.OBJECT: {"level": "local"}}, + ), + BsonTypeTestCase( + id="level_field", + msg="readConcern.level should reject non-string BSON types", + valid_types=[BsonType.STRING], + skip_rejection_types=[BsonType.NULL], + default_error_code=TYPE_MISMATCH_ERROR, + expected=[{"_id": 1, "x": 1}], + valid_inputs={BsonType.STRING: "local"}, + ), +] + + +def _build_read_concern(spec, sample_value): + """Build the readConcern value based on which aspect is being tested.""" + if spec.id == "read_concern_field": + return sample_value + # level_field: wrap the sample as the level sub-field. + return {"level": sample_value} + + +@pytest.mark.parametrize( + "bson_type,sample_value,spec", generate_bson_rejection_test_cases(READ_CONCERN_PARAMS) +) +def test_read_concern_bson_type_rejected(collection, bson_type, sample_value, spec): + """Test readConcern rejects invalid BSON types for the field and level sub-field.""" + collection.insert_one({"_id": 1, "x": 1}) + result = execute_command( + collection, + { + "find": collection.name, + "filter": {}, + "readConcern": _build_read_concern(spec, sample_value), + }, + ) + assertFailureCode( + result, + spec.expected_code(bson_type), + msg=f"readConcern should reject {bson_type.value} for {spec.id}", + ) + + +@pytest.mark.parametrize( + "bson_type,sample_value,spec", generate_bson_acceptance_test_cases(READ_CONCERN_PARAMS) +) +def test_read_concern_bson_type_accepted(collection, bson_type, sample_value, spec): + """Test readConcern accepts valid BSON types for the field and level sub-field.""" + collection.insert_one({"_id": 1, "x": 1}) + result = execute_command( + collection, + { + "find": collection.name, + "filter": {}, + "readConcern": _build_read_concern(spec, sample_value), + }, + ) + assertSuccess( + result, spec.expected, msg=f"readConcern should accept {bson_type.value} for {spec.id}" + ) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_command_interaction.py b/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_command_interaction.py new file mode 100644 index 000000000..abeb42381 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_command_interaction.py @@ -0,0 +1,134 @@ +"""readConcern with find: command options, empty/non-existent collections, views, and getMore.""" + +from typing import Any, Dict, cast + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import 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 +from documentdb_tests.framework.target_collection import ViewCollection + +INTERACTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "find_with_options_and_read_concern", + docs=[{"_id": 1, "x": 3, "y": 9}, {"_id": 2, "x": 1, "y": 9}, {"_id": 3, "x": 2, "y": 9}], + command={ + "filter": {}, + "sort": {"x": 1}, + "projection": {"x": 1, "_id": 1}, + "limit": 2, + "readConcern": {"level": "local"}, + }, + expected=[{"_id": 2, "x": 1}, {"_id": 3, "x": 2}], + msg="find with readConcern should not interfere with sort/projection/limit options.", + ), + CommandTestCase( + "find_on_empty_collection", + docs=[], + command={"filter": {}, "readConcern": {"level": "local"}}, + expected=[], + msg="find with readConcern on empty collection should return empty.", + ), + CommandTestCase( + "find_on_nonexistent_collection", + docs=None, + command={"filter": {}, "readConcern": {"level": "local"}}, + expected=[], + msg="find with readConcern on non-existent collection should return empty.", + ), + CommandTestCase( + "find_on_view", + target_collection=ViewCollection(options={"pipeline": [{"$match": {"x": {"$gte": 10}}}]}), + docs=[{"_id": 1, "x": 10}, {"_id": 2, "x": 20}, {"_id": 3, "x": 5}], + command={"filter": {}, "sort": {"_id": 1}, "readConcern": {"level": "local"}}, + expected=[{"_id": 1, "x": 10}, {"_id": 2, "x": 20}], + msg="find with readConcern on view should return filtered view results.", + ), + CommandTestCase( + "find_first_batch_with_batch_size", + docs=[{"_id": i, "x": i} for i in range(10)], + command={ + "filter": {}, + "batchSize": 3, + "sort": {"_id": 1}, + "readConcern": {"level": "local"}, + }, + expected=[{"_id": 0, "x": 0}, {"_id": 1, "x": 1}, {"_id": 2, "x": 2}], + msg="first batch from find with readConcern should contain 3 documents.", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(INTERACTION_TESTS)) +def test_read_concern_command_interaction(collection, test: CommandTestCase): + """Test readConcern works with other command options, collection states, and views.""" + collection = test.prepare(collection.database, collection) + find_body = cast(Dict[str, Any], test.command) + result = execute_command(collection, {"find": collection.name, **find_body}) + assertResult(result, expected=test.expected, msg=test.msg) + + +def test_getmore_after_find_with_read_concern_next_batch(collection): + """Test getMore after find with readConcern returns next batch correctly.""" + collection.insert_many([{"_id": i, "x": i} for i in range(10)]) + + initial_result = execute_command( + collection, + { + "find": collection.name, + "filter": {}, + "batchSize": 3, + "sort": {"_id": 1}, + "readConcern": {"level": "local"}, + }, + ) + cursor_id = initial_result["cursor"]["id"] + + getmore_result = execute_command( + collection, + {"getMore": cursor_id, "collection": collection.name, "batchSize": 3}, + ) + expected_next = [{"_id": 3, "x": 3}, {"_id": 4, "x": 4}, {"_id": 5, "x": 5}] + assertProperties( + getmore_result, + {"cursor.nextBatch": Eq(expected_next), "ok": Eq(1.0)}, + raw_res=True, + msg="getMore after readConcern find should return next batch correctly.", + ) + + +def test_getmore_ignores_read_concern_parameter(collection): + """Test getMore ignores a readConcern parameter and still returns the next batch.""" + collection.insert_many([{"_id": i, "x": i} for i in range(10)]) + + initial_result = execute_command( + collection, + { + "find": collection.name, + "filter": {}, + "batchSize": 3, + "sort": {"_id": 1}, + "readConcern": {"level": "local"}, + }, + ) + cursor_id = initial_result["cursor"]["id"] + + getmore_result = execute_command( + collection, + { + "getMore": cursor_id, + "collection": collection.name, + "batchSize": 3, + "readConcern": {"level": "local"}, + }, + ) + expected_next = [{"_id": 3, "x": 3}, {"_id": 4, "x": 4}, {"_id": 5, "x": 5}] + assertProperties( + getmore_result, + {"cursor.nextBatch": Eq(expected_next), "ok": Eq(1.0)}, + raw_res=True, + msg="getMore should ignore a readConcern parameter and return the next batch.", + ) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_errors.py b/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_errors.py new file mode 100644 index 000000000..1191e7399 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_errors.py @@ -0,0 +1,134 @@ +"""readConcern rejection cases with find: bad levels, unknown fields, topology, afterClusterTime.""" + +from typing import Any, Dict, cast + +import pytest + +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, + NOT_A_REPLICA_SET_ERROR, + TYPE_MISMATCH_ERROR, + UNRECOGNIZED_COMMAND_FIELD_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +_REQUIRES_STANDALONE = (pytest.mark.requires(cluster_read_concern=False),) +_REQUIRES_REPLICA_SET = (pytest.mark.requires(cluster_read_concern=True),) + +INVALID_LEVEL_STRING_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "find_rejects_empty_string_level", + command={"filter": {}, "readConcern": {"level": ""}}, + error_code=BAD_VALUE_ERROR, + msg="find should reject empty readConcern level string.", + ), + CommandTestCase( + "find_rejects_unknown_level_string", + command={"filter": {}, "readConcern": {"level": "invalid"}}, + error_code=BAD_VALUE_ERROR, + msg="find should reject unrecognized readConcern level string 'invalid'.", + ), + CommandTestCase( + "find_rejects_uppercase_level", + command={"filter": {}, "readConcern": {"level": "LOCAL"}}, + error_code=BAD_VALUE_ERROR, + msg="find should reject uppercase readConcern level 'LOCAL'.", + ), + CommandTestCase( + "find_rejects_mixed_case_level", + command={"filter": {}, "readConcern": {"level": "Majority"}}, + error_code=BAD_VALUE_ERROR, + msg="find should reject mixed-case readConcern level 'Majority'.", + ), + CommandTestCase( + "find_rejects_nonexistent_level", + command={"filter": {}, "readConcern": {"level": "strong"}}, + error_code=BAD_VALUE_ERROR, + msg="find should reject nonexistent readConcern level 'strong'.", + ), + CommandTestCase( + "find_rejects_null_byte_in_level", + command={"filter": {}, "readConcern": {"level": "local\x00extra"}}, + error_code=BAD_VALUE_ERROR, + msg="find should reject null byte in readConcern level string.", + ), +] + +UNKNOWN_FIELD_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "find_rejects_unknown_field_no_level", + command={"filter": {}, "readConcern": {"unknownField": 1}}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="find should reject readConcern with unknown field and no level.", + ), + CommandTestCase( + "find_rejects_extra_field_with_valid_level", + command={"filter": {}, "readConcern": {"level": "local", "unknownField": 1}}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="find should reject readConcern with extra unknown field.", + ), +] + + +AFTER_CLUSTER_TIME_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "find_rejects_afterClusterTime_string", + command={"filter": {}, "readConcern": {"level": "local", "afterClusterTime": "invalid"}}, + error_code=TYPE_MISMATCH_ERROR, + msg="find should reject non-Timestamp afterClusterTime (string).", + marks=_REQUIRES_REPLICA_SET, + ), + CommandTestCase( + "find_rejects_afterClusterTime_integer", + command={"filter": {}, "readConcern": {"level": "local", "afterClusterTime": 12345}}, + error_code=TYPE_MISMATCH_ERROR, + msg="find should reject non-Timestamp afterClusterTime (integer).", + marks=_REQUIRES_REPLICA_SET, + ), + CommandTestCase( + "find_rejects_afterClusterTime_null", + command={"filter": {}, "readConcern": {"level": "local", "afterClusterTime": None}}, + error_code=TYPE_MISMATCH_ERROR, + msg="find should reject non-Timestamp afterClusterTime (null).", + marks=_REQUIRES_REPLICA_SET, + ), +] + +# 'snapshot' and 'linearizable' both require a replicated topology and are rejected on standalone. +REPLICA_SET_ONLY_LEVEL_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "find_rejects_snapshot_on_standalone", + docs=[{"_id": 1}], + command={"filter": {}, "readConcern": {"level": "snapshot"}}, + error_code=NOT_A_REPLICA_SET_ERROR, + msg="readConcern 'snapshot' should be rejected on a standalone (not a replica set).", + marks=_REQUIRES_STANDALONE, + ), + CommandTestCase( + "find_rejects_linearizable_on_standalone", + docs=[{"_id": 1}], + command={"filter": {}, "readConcern": {"level": "linearizable"}}, + error_code=NOT_A_REPLICA_SET_ERROR, + msg="readConcern 'linearizable' should be rejected on a standalone (not a replica set).", + marks=_REQUIRES_STANDALONE, + ), +] + +ERROR_TESTS: list[CommandTestCase] = ( + INVALID_LEVEL_STRING_TESTS + + UNKNOWN_FIELD_TESTS + + REPLICA_SET_ONLY_LEVEL_TESTS + + AFTER_CLUSTER_TIME_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ERROR_TESTS)) +def test_read_concern_rejected(collection, test: CommandTestCase): + """Test readConcern rejection cases return the expected error code.""" + collection = test.prepare(collection.database, collection) + find_body = cast(Dict[str, Any], test.command) + result = execute_command(collection, {"find": collection.name, **find_body}) + assertResult(result, error_code=test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_level_acceptance.py b/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_level_acceptance.py new file mode 100644 index 000000000..731c46d16 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_level_acceptance.py @@ -0,0 +1,94 @@ +"""readConcern acceptance with find: valid levels, default shapes, and valid afterClusterTime.""" + +from typing import Any, Dict, cast + +import pytest +from bson import 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_command +from documentdb_tests.framework.parametrize import pytest_params + +_TWO_DOCS = [{"_id": 1, "x": 1}, {"_id": 2, "x": 2}] + +ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "find_accepts_local", + docs=_TWO_DOCS, + command={"filter": {}, "sort": {"_id": 1}, "readConcern": {"level": "local"}}, + expected=_TWO_DOCS, + msg="find should accept readConcern level 'local'.", + ), + CommandTestCase( + "find_accepts_available", + docs=_TWO_DOCS, + command={"filter": {}, "sort": {"_id": 1}, "readConcern": {"level": "available"}}, + expected=_TWO_DOCS, + msg="find should accept readConcern level 'available'.", + ), + CommandTestCase( + "find_accepts_majority", + docs=_TWO_DOCS, + command={"filter": {}, "sort": {"_id": 1}, "readConcern": {"level": "majority"}}, + expected=_TWO_DOCS, + msg="find should accept readConcern level 'majority'.", + ), + CommandTestCase( + "find_accepts_linearizable", + docs=[{"_id": 1, "x": 1}], + command={"filter": {"_id": 1}, "readConcern": {"level": "linearizable"}}, + expected=[{"_id": 1, "x": 1}], + msg="find should accept readConcern level 'linearizable'.", + marks=(pytest.mark.requires(cluster_read_concern=True),), + ), + CommandTestCase( + "find_accepts_snapshot", + docs=[{"_id": 1, "x": 1}], + command={"filter": {"_id": 1}, "readConcern": {"level": "snapshot"}}, + expected=[{"_id": 1, "x": 1}], + msg="find should accept readConcern level 'snapshot' on a replica set.", + marks=(pytest.mark.requires(cluster_read_concern=True),), + ), + CommandTestCase( + "find_accepts_empty_document", + docs=[{"_id": 1, "x": 1}], + command={"filter": {}, "readConcern": {}}, + expected=[{"_id": 1, "x": 1}], + msg="find should accept empty readConcern document.", + ), + CommandTestCase( + "find_accepts_null_read_concern", + docs=[{"_id": 1, "x": 1}], + command={"filter": {}, "readConcern": None}, + expected=[{"_id": 1, "x": 1}], + msg="find should treat null readConcern as omitted.", + ), + CommandTestCase( + "find_accepts_null_level", + docs=[{"_id": 1, "x": 1}], + command={"filter": {}, "readConcern": {"level": None}}, + expected=[{"_id": 1, "x": 1}], + msg="find should treat readConcern {level: null} as implicit default.", + ), + CommandTestCase( + "find_accepts_valid_after_cluster_time", + docs=[{"_id": 1, "x": 1}], + command={ + "filter": {"_id": 1}, + "readConcern": {"level": "local", "afterClusterTime": Timestamp(1, 1)}, + }, + expected=[{"_id": 1, "x": 1}], + msg="find should accept a valid Timestamp afterClusterTime on a replica set.", + marks=(pytest.mark.requires(cluster_read_concern=True),), + ), +] + + +@pytest.mark.parametrize("test", pytest_params(ACCEPTANCE_TESTS)) +def test_read_concern_level_acceptance(collection, test: CommandTestCase): + """Test readConcern level (and default-equivalent shapes) is accepted by find.""" + collection = test.prepare(collection.database, collection) + find_body = cast(Dict[str, Any], test.command) + result = execute_command(collection, {"find": collection.name, **find_body}) + assertResult(result, expected=test.expected, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_local.py b/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_local.py new file mode 100644 index 000000000..1dbc3e7d3 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_local.py @@ -0,0 +1,77 @@ +"""readConcern level 'local' availability and behavior with find.""" + +from typing import Any, Dict, cast + +import pytest + +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_command +from documentdb_tests.framework.parametrize import pytest_params + + +def _update_status(coll): + """Update _id 1's status to 'done' via a write command.""" + execute_command( + coll, + {"update": coll.name, "updates": [{"q": {"_id": 1}, "u": {"$set": {"status": "done"}}}]}, + ) + + +def _delete_first(coll): + """Delete _id 1 via a write command.""" + execute_command(coll, {"delete": coll.name, "deletes": [{"q": {"_id": 1}, "limit": 1}]}) + + +# Each ``command`` is the find body (everything except the collection name); the +# test runner prepends ``"find": ``. +LOCAL_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "find_omitted_read_concern_is_default", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 2}], + command={"filter": {}, "sort": {"_id": 1}}, + expected=[{"_id": 1, "x": 1}, {"_id": 2, "x": 2}], + msg="find without readConcern should return all documents (implicit default).", + ), + CommandTestCase( + "find_local_without_session", + docs=[{"_id": 1, "v": "a"}, {"_id": 2, "v": "b"}], + command={"filter": {}, "sort": {"_id": 1}, "readConcern": {"level": "local"}}, + expected=[{"_id": 1, "v": "a"}, {"_id": 2, "v": "b"}], + msg="find with readConcern 'local' must be available without a session or transaction.", + ), + CommandTestCase( + "find_reads_fresh_data", + docs=[{"_id": 1, "score": 100}], + command={"filter": {"score": 100}, "readConcern": {"level": "local"}}, + expected=[{"_id": 1, "score": 100}], + msg="find with readConcern 'local' should return inserted documents.", + ), + CommandTestCase( + "find_sees_updated_document", + docs=[{"_id": 1, "status": "pending"}], + setup=_update_status, + command={"filter": {"_id": 1}, "readConcern": {"level": "local"}}, + expected=[{"_id": 1, "status": "done"}], + msg="find with readConcern 'local' must reflect an update applied to the local instance.", + ), + CommandTestCase( + "find_reflects_delete", + docs=[{"_id": 1}, {"_id": 2}, {"_id": 3}], + setup=_delete_first, + command={"filter": {}, "sort": {"_id": 1}, "readConcern": {"level": "local"}}, + expected=[{"_id": 2}, {"_id": 3}], + msg="find with readConcern 'local' must reflect a deletion applied to the local instance.", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(LOCAL_TESTS)) +def test_read_concern_local(collection, test: CommandTestCase): + """Test readConcern level 'local' availability and behavior.""" + collection = test.prepare(collection.database, collection) + if test.setup: + test.setup(collection) + find_body = cast(Dict[str, Any], test.command) + result = execute_command(collection, {"find": collection.name, **find_body}) + assertResult(result, expected=test.expected, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_write_commands.py b/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_write_commands.py new file mode 100644 index 000000000..8a4ffa589 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_write_commands.py @@ -0,0 +1,70 @@ +"""readConcern on write commands (insert, update, delete, findAndModify) outside transactions.""" + +from typing import Any, Dict, cast + +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_command +from documentdb_tests.framework.parametrize import pytest_params + +WRITE_COMMAND_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "insert_with_read_concern", + command=lambda ctx: { + "insert": ctx.collection, + "documents": [{"_id": 1, "x": 1}], + "readConcern": {"level": "local"}, + }, + expected={"n": 1, "ok": 1.0}, + msg="insert should accept readConcern outside transaction.", + ), + CommandTestCase( + "update_with_read_concern", + docs=[{"_id": 1, "x": 1}], + command=lambda ctx: { + "update": ctx.collection, + "updates": [{"q": {"_id": 1}, "u": {"$set": {"x": 2}}}], + "readConcern": {"level": "local"}, + }, + expected={"n": 1, "nModified": 1, "ok": 1.0}, + msg="update should accept readConcern outside transaction.", + ), + CommandTestCase( + "delete_with_read_concern", + docs=[{"_id": 1}], + command=lambda ctx: { + "delete": ctx.collection, + "deletes": [{"q": {"_id": 1}, "limit": 1}], + "readConcern": {"level": "local"}, + }, + expected={"n": 1, "ok": 1.0}, + msg="delete should accept readConcern outside transaction.", + ), + CommandTestCase( + "find_and_modify_with_read_concern", + docs=[{"_id": 1, "x": 1}], + command=lambda ctx: { + "findAndModify": ctx.collection, + "query": {"_id": 1}, + "update": {"$set": {"x": 2}}, + "readConcern": {"level": "local"}, + }, + expected={"value": {"_id": 1, "x": 1}, "ok": 1.0}, + msg="findAndModify should accept readConcern outside transaction.", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(WRITE_COMMAND_TESTS)) +def test_write_command_with_read_concern(collection, test: CommandTestCase): + """Test write commands accept readConcern outside transaction.""" + collection = test.prepare(collection.database, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + expected = cast(Dict[str, Any], test.build_expected(ctx)) + assertSuccessPartial(result, expected, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_smoke_read_concern.py b/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_smoke_read_concern.py index 140e0a288..dc0f2eae2 100644 --- a/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_smoke_read_concern.py +++ b/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_smoke_read_concern.py @@ -1,8 +1,4 @@ -""" -Smoke test for readConcern. - -Tests basic readConcern functionality. -""" +"""Smoke test for basic readConcern functionality.""" import pytest diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_smoke_write_concern.py b/documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_smoke_write_concern.py index a0864ac37..410e337da 100644 --- a/documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_smoke_write_concern.py +++ b/documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_smoke_write_concern.py @@ -1,27 +1,33 @@ -""" -Smoke test for writeConcern. +"""writeConcern smoke test: a basic insert with w:1 succeeds.""" -Tests basic writeConcern functionality. -""" +from typing import Any, Dict, cast import pytest -from documentdb_tests.framework.assertions import assertSuccessPartial +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_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq pytestmark = pytest.mark.smoke - -def test_smoke_write_concern(collection): - """Test basic writeConcern behavior.""" - result = execute_command( - collection, - { - "insert": collection.name, +SMOKE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "insert_w1", + command={ "documents": [{"_id": 1, "name": "test"}], "writeConcern": {"w": 1}, }, - ) + expected={"ok": Eq(1.0), "n": Eq(1)}, + msg="Should support writeConcern", + ), +] - expected = {"ok": 1.0, "n": 1} - assertSuccessPartial(result, expected, msg="Should support writeConcern") + +@pytest.mark.parametrize("test", pytest_params(SMOKE_TESTS)) +def test_smoke_write_concern(collection, test: CommandTestCase): + """Test basic writeConcern behavior.""" + insert_body = cast(Dict[str, Any], test.command) + result = execute_command(collection, {"insert": collection.name, **insert_body}) + assertResult(result, expected=test.expected, msg=test.msg, raw_res=True) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_write_concern_acceptance.py b/documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_write_concern_acceptance.py new file mode 100644 index 000000000..b1243980d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_write_concern_acceptance.py @@ -0,0 +1,235 @@ +"""writeConcern acceptance: valid w/j/wtimeout/provenance values and their +combinations, plus writeConcern:null behaving like an omitted writeConcern. +""" + +from typing import Any, Dict, cast + +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 assertResult, assertSuccessPartial +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_NEGATIVE_INFINITY, + FLOAT_NEGATIVE_INFINITY, + INT32_MAX, + INT32_MIN, +) + +W_ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "w_0", + command={"updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], "writeConcern": {"w": 0}}, + expected={"ok": Eq(1.0)}, + msg="w:0 should be accepted.", + ), + CommandTestCase( + "w_double_coerced", + command={ + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": 1.0}, + }, + expected={"ok": Eq(1.0)}, + msg="w:1.0 should coerce and be accepted.", + ), + CommandTestCase( + "w_int64_coerced", + command={ + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": Int64(1)}, + }, + expected={"ok": Eq(1.0)}, + msg="w as Int64 should coerce and be accepted.", + ), + CommandTestCase( + "w_decimal128_coerced", + command={ + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": Decimal128("1")}, + }, + expected={"ok": Eq(1.0)}, + msg="w as Decimal128 should coerce and be accepted.", + ), + CommandTestCase( + "w_int64_0", + command={ + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": Int64(0)}, + }, + expected={"ok": Eq(1.0)}, + msg="w as Int64(0) should be accepted.", + ), + CommandTestCase( + "w_negative_zero", + command={ + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": -0.0}, + }, + expected={"ok": Eq(1.0)}, + msg="w:-0.0 should be accepted.", + ), + CommandTestCase( + "w_fractional_0_5", + command={ + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": 0.5}, + }, + expected={"ok": Eq(1.0)}, + msg="w:0.5 should be accepted.", + ), + CommandTestCase( + "w_fractional_1_5", + command={ + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": 1.5}, + }, + expected={"ok": Eq(1.0)}, + msg="w:1.5 should be accepted.", + ), +] + + +WTIMEOUT_ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "int32_max", + command={ + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": 1, "wtimeout": INT32_MAX}, + }, + expected={"ok": Eq(1.0)}, + msg="wtimeout INT32_MAX ok.", + ), + CommandTestCase( + "int32_min", + command={ + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": 1, "wtimeout": INT32_MIN}, + }, + expected={"ok": Eq(1.0)}, + msg="wtimeout INT32_MIN ok.", + ), + CommandTestCase( + "negative_inf", + command={ + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": 1, "wtimeout": FLOAT_NEGATIVE_INFINITY}, + }, + expected={"ok": Eq(1.0)}, + msg="wtimeout -Infinity ok.", + ), + CommandTestCase( + "decimal128_neg_inf", + command={ + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": 1, "wtimeout": DECIMAL128_NEGATIVE_INFINITY}, + }, + expected={"ok": Eq(1.0)}, + msg="wtimeout Decimal128 -Infinity ok.", + ), + CommandTestCase( + "zero", + command={ + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": 1, "wtimeout": 0}, + }, + expected={"ok": Eq(1.0)}, + msg="wtimeout 0 ok.", + ), + CommandTestCase( + "negative", + command={ + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": 1, "wtimeout": -1}, + }, + expected={"ok": Eq(1.0)}, + msg="wtimeout negative ok.", + ), + CommandTestCase( + "with_w0", + command={ + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": 0, "wtimeout": 5_000}, + }, + expected={"ok": Eq(1.0)}, + msg="wtimeout with w:0 ok.", + ), +] + + +# Sub-fields compose in one writeConcern document. +COMBINATION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "all_three", + command={ + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": 1, "j": True, "wtimeout": 5_000}, + }, + expected={"ok": Eq(1.0)}, + msg="w + j + wtimeout together should be accepted.", + ), +] + + +# provenance acceptance (a representative value plus null). +PROVENANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "clientSupplied", + command={ + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": 1, "provenance": "clientSupplied"}, + }, + expected={"ok": Eq(1.0)}, + msg="provenance:'clientSupplied' should be accepted.", + ), + CommandTestCase( + "null", + command={ + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": 1, "provenance": None}, + }, + expected={"ok": Eq(1.0)}, + msg="provenance:null should be accepted.", + ), +] + +WRITE_CONCERN_ACCEPTANCE_TESTS = ( + W_ACCEPTANCE_TESTS + WTIMEOUT_ACCEPTANCE_TESTS + COMBINATION_TESTS + PROVENANCE_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(WRITE_CONCERN_ACCEPTANCE_TESTS)) +def test_write_concern_accepted(collection, test: CommandTestCase): + """Test writeConcern accepts valid sub-field values and combinations.""" + collection.insert_one({"_id": 1, "a": 0}) + update_body = cast(Dict[str, Any], test.command) + result = execute_command(collection, {"update": collection.name, **update_body}) + assertResult(result, expected=test.expected, msg=test.msg, raw_res=True) + + +def test_write_concern_null_equivalent_to_omitted(collection): + """Test writeConcern null produces the same response as omitting writeConcern.""" + collection.insert_many([{"_id": 1, "a": 0}, {"_id": 2, "a": 0}]) + omitted = execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + }, + ) + explicit_null = execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 2}, "u": {"$set": {"a": 1}}}], + "writeConcern": None, + }, + ) + expected = {k: omitted[k] for k in ("ok", "n", "nModified") if k in omitted} + assertSuccessPartial( + explicit_null, + expected, + msg="update with writeConcern:null should match an omitted writeConcern.", + ) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_write_concern_behavior.py b/documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_write_concern_behavior.py new file mode 100644 index 000000000..ef6e8915b --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_write_concern_behavior.py @@ -0,0 +1,186 @@ +"""writeConcern behavior: w:0 unacknowledged writes, j:true overriding w:0, +findAndModify return semantics, and ordered interaction. + +The "still performs the write" checks are hand-written (not CommandTestCase +rows) because they verify the effect with a second find command. +""" + +from typing import Any, Dict, cast + +import pytest + +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_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, NotExists + +BEHAVIOR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "update_j_true_overrides_w0", + docs=[{"_id": 1}], + command={ + "verb": "update", + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": 0, "j": True}, + }, + expected={"ok": Eq(1.0)}, + msg="update with j:true should override w:0.", + ), + CommandTestCase( + "delete_j_true_overrides_w0", + docs=[{"_id": 99}], + command={ + "verb": "delete", + "deletes": [{"q": {"_id": 99}, "limit": 1}], + "writeConcern": {"w": 0, "j": True}, + }, + expected={"ok": Eq(1.0)}, + msg="delete with j:true should override w:0.", + ), + CommandTestCase( + "findAndModify_j_true_overrides_w0", + docs=[{"_id": 1}], + command={ + "verb": "findAndModify", + "query": {"_id": 1}, + "update": {"$set": {"a": 1}}, + "writeConcern": {"w": 0, "j": True}, + }, + expected={"ok": Eq(1.0)}, + msg="findAndModify with j:true should override w:0.", + ), + CommandTestCase( + "findAndModify_w0_accepts_and_performs_write", + docs=[{"_id": 1, "a": 0}], + command={ + "verb": "findAndModify", + "query": {"_id": 1}, + "update": {"$set": {"a": 99}}, + "new": True, + "writeConcern": {"w": 0}, + }, + expected={"value": {"_id": Eq(1), "a": Eq(99)}}, + msg="findAndModify with w:0 should still perform the write.", + ), + CommandTestCase( + "findAndModify_new_true", + docs=[{"_id": 1, "a": 0}], + command={ + "verb": "findAndModify", + "query": {"_id": 1}, + "update": {"$set": {"a": 99}}, + "new": True, + "writeConcern": {"w": 1}, + }, + expected={"value": {"_id": Eq(1), "a": Eq(99)}}, + msg="findAndModify new:true should return modified doc.", + ), + CommandTestCase( + "findAndModify_new_false", + docs=[{"_id": 1, "a": 0}], + command={ + "verb": "findAndModify", + "query": {"_id": 1}, + "update": {"$set": {"a": 99}}, + "new": False, + "writeConcern": {"w": 1}, + }, + expected={"value": {"_id": Eq(1), "a": Eq(0)}}, + msg="findAndModify new:false should return original doc.", + ), + CommandTestCase( + "findAndModify_remove", + docs=[{"_id": 1, "a": 0}], + command={ + "verb": "findAndModify", + "query": {"_id": 1}, + "remove": True, + "writeConcern": {"w": 1}, + }, + expected={"value": {"_id": Eq(1), "a": Eq(0)}}, + msg="findAndModify remove:true should return removed doc.", + ), + CommandTestCase( + "update_ordered_true", + docs=[{"_id": 1}], + command={ + "verb": "update", + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "ordered": True, + "writeConcern": {"w": 1}, + }, + expected={"ok": Eq(1.0)}, + msg="update with ordered:true and writeConcern should succeed.", + ), + CommandTestCase( + "update_ordered_false", + docs=[{"_id": 1}], + command={ + "verb": "update", + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "ordered": False, + "writeConcern": {"w": 1}, + }, + expected={"ok": Eq(1.0)}, + msg="update with ordered:false and writeConcern should succeed.", + ), + # w:0 suppresses the per-operation error that w:1 surfaces. The w:1 "surfaces" + # counterpart lives in test_write_concern_errors.py (w1_surfaces_operation_error). + # multi:true with a replacement doc is the invalid operation under test. + CommandTestCase( + "w0_suppresses_operation_error", + docs=[{"_id": 1, "a": 1}], + command={ + "verb": "update", + "updates": [{"q": {}, "u": {"a": 2}, "multi": True}], + "writeConcern": {"w": 0}, + }, + expected={"writeErrors": NotExists()}, + msg="update with w:0 should suppress the operation error.", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(BEHAVIOR_TESTS)) +def test_write_concern_behavior(collection, test: CommandTestCase): + """Test single-command writeConcern behaviors.""" + collection = test.prepare(collection.database, collection) + body = dict(cast(Dict[str, Any], test.command)) + verb = body.pop("verb") + result = execute_command(collection, {verb: collection.name, **body}) + assertResult(result, expected=test.expected, msg=test.msg, raw_res=True) + + +def test_update_w0_performs_write(collection): + """Test update with w:0 still performs the write.""" + collection.insert_one({"_id": 1, "a": 0}) + execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 99}}}], + "writeConcern": {"w": 0}, + }, + ) + result = execute_command(collection, {"find": collection.name, "filter": {"_id": 1}}) + assertResult( + result, + expected=[{"_id": 1, "a": 99}], + msg="update with w:0 should still perform the write.", + ) + + +def test_delete_w0_performs_delete(collection): + """Test delete with w:0 still performs the delete.""" + collection.insert_one({"_id": 1}) + execute_command( + collection, + { + "delete": collection.name, + "deletes": [{"q": {"_id": 1}, "limit": 1}], + "writeConcern": {"w": 0}, + }, + ) + result = execute_command(collection, {"find": collection.name, "filter": {"_id": 1}}) + assertResult(result, expected=[], msg="delete with w:0 should still perform the delete.") diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_write_concern_bson_type_validation.py b/documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_write_concern_bson_type_validation.py new file mode 100644 index 000000000..24583765d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_write_concern_bson_type_validation.py @@ -0,0 +1,112 @@ +"""writeConcern BSON type validation: the writeConcern field accepts only a +document or null, and the w/j/wtimeout sub-fields accept their supported BSON +types; all other types are rejected. +""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.framework.assertions import assertResult, 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 FAILED_TO_PARSE_ERROR, TYPE_MISMATCH_ERROR +from documentdb_tests.framework.executor import execute_command + +WRITE_CONCERN_PARAMS = [ + BsonTypeTestCase( + id="write_concern_field", + msg="writeConcern should reject non-document types", + valid_types=[BsonType.OBJECT, BsonType.NULL], + default_error_code=TYPE_MISMATCH_ERROR, + valid_inputs={BsonType.OBJECT: {"w": 1}}, + ), +] + +SUB_FIELD_PARAMS = [ + BsonTypeTestCase( + id="w", + msg="w should accept numbers, 'majority', and tagged objects", + valid_types=[ + BsonType.INT, + BsonType.LONG, + BsonType.DOUBLE, + BsonType.DECIMAL, + BsonType.STRING, + BsonType.OBJECT, + ], + skip_rejection_types=[BsonType.NULL], + default_error_code=FAILED_TO_PARSE_ERROR, + valid_inputs={ + BsonType.INT: 1, + BsonType.LONG: Int64(1), + BsonType.DOUBLE: 1.0, + BsonType.DECIMAL: Decimal128("1"), + BsonType.STRING: "majority", + BsonType.OBJECT: {"dc1": 1}, + }, + ), + BsonTypeTestCase( + id="j", + msg="j should accept boolean, numeric types, and null", + valid_types=[ + BsonType.BOOL, + BsonType.INT, + BsonType.LONG, + BsonType.DOUBLE, + BsonType.DECIMAL, + BsonType.NULL, + ], + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="wtimeout", + msg="wtimeout should accept all BSON types", + valid_types=list(BsonType), + default_error_code=FAILED_TO_PARSE_ERROR, + valid_inputs={BsonType.LONG: Int64(5_000)}, + ), +] + + +def _build_command(collection_name, spec, sample_value): + """Build an update command placing the sample value per the spec.""" + if spec.id == "write_concern_field": + write_concern = sample_value + elif spec.id == "w": + write_concern = {"w": sample_value} + else: + write_concern = {"w": 1, spec.id: sample_value} + return { + "update": collection_name, + "updates": [{"q": {}, "u": {"$set": {"a": 1}}}], + "writeConcern": write_concern, + } + + +_ALL_PARAMS = WRITE_CONCERN_PARAMS + SUB_FIELD_PARAMS + + +@pytest.mark.parametrize( + "bson_type,sample_value,spec", generate_bson_acceptance_test_cases(_ALL_PARAMS) +) +def test_write_concern_bson_type_accepted(collection, bson_type, sample_value, spec): + """Test the writeConcern field and sub-fields accept their supported BSON types.""" + result = execute_command(collection, _build_command(collection.name, spec, sample_value)) + assertSuccessPartial(result, {"ok": 1.0}, msg=f"{spec.id} should accept {bson_type.value}") + + +@pytest.mark.parametrize( + "bson_type,sample_value,spec", generate_bson_rejection_test_cases(_ALL_PARAMS) +) +def test_write_concern_bson_type_rejected(collection, bson_type, sample_value, spec): + """Test the writeConcern field and sub-fields reject unsupported BSON types.""" + result = execute_command(collection, _build_command(collection.name, spec, sample_value)) + assertResult( + result, + error_code=spec.expected_code(bson_type), + msg=f"{spec.id} should reject {bson_type.value}", + ) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_write_concern_errors.py b/documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_write_concern_errors.py new file mode 100644 index 000000000..e3ce10cc1 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_write_concern_errors.py @@ -0,0 +1,281 @@ +"""writeConcern rejection: invalid w/j/wtimeout values, case-sensitive w strings, +invalid provenance, and unknown fields, plus the w:1 case where a per-operation +error surfaces. Each is expected to fail with a specific error code. BSON-type +rejection lives in test_write_concern_bson_type_validation.py. +""" + +from typing import Any, Dict, cast + +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 assertResult +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + FAILED_TO_PARSE_ERROR, + TYPE_MISMATCH_ERROR, + UNRECOGNIZED_COMMAND_FIELD_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, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + FLOAT_NEGATIVE_NAN, + INT32_MAX, + INT32_OVERFLOW, + INT64_MAX, + INT64_MIN, +) + +# These cases are only rejected on standalone; a quorum target treats them as +# custom tag names (valid), so they are deselected there. +_STANDALONE_ONLY = (pytest.mark.requires(quorum_write_concern=False),) + +WRITE_CONCERN_REJECTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "w_null", + command={"updates": [{"q": {}, "u": {"$set": {"a": 1}}}], "writeConcern": {"w": None}}, + error_code=BAD_VALUE_ERROR, + msg="w:null should be rejected on standalone.", + marks=_STANDALONE_ONLY, + ), + CommandTestCase( + "w_negative", + command={"updates": [{"q": {}, "u": {"$set": {"a": 1}}}], "writeConcern": {"w": -1}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="w:-1 should be rejected.", + ), + CommandTestCase( + "w_exceeds_50", + command={"updates": [{"q": {}, "u": {"$set": {"a": 1}}}], "writeConcern": {"w": 51}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="w:51 should be rejected.", + ), + CommandTestCase( + "w_float_nan", + command={"updates": [{"q": {}, "u": {"$set": {"a": 1}}}], "writeConcern": {"w": FLOAT_NAN}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="w as NaN should be rejected.", + ), + CommandTestCase( + "w_float_neg_nan", + command={ + "updates": [{"q": {}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": FLOAT_NEGATIVE_NAN}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="w as -NaN should be rejected.", + ), + CommandTestCase( + "w_decimal128_nan", + command={ + "updates": [{"q": {}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": DECIMAL128_NAN}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="w as Decimal128 NaN should be rejected.", + ), + CommandTestCase( + "w_decimal128_neg_nan", + command={ + "updates": [{"q": {}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": DECIMAL128_NEGATIVE_NAN}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="w as Decimal128 -NaN should be rejected.", + ), + CommandTestCase( + "w_float_inf", + command={ + "updates": [{"q": {}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": FLOAT_INFINITY}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="w as +Infinity should be rejected.", + ), + CommandTestCase( + "w_float_neg_inf", + command={ + "updates": [{"q": {}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": FLOAT_NEGATIVE_INFINITY}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="w as -Infinity should be rejected.", + ), + CommandTestCase( + "w_decimal128_inf", + command={ + "updates": [{"q": {}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": DECIMAL128_INFINITY}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="w as Decimal128 +Infinity should be rejected.", + ), + CommandTestCase( + "w_decimal128_neg_inf", + command={ + "updates": [{"q": {}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": DECIMAL128_NEGATIVE_INFINITY}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="w as Decimal128 -Infinity should be rejected.", + ), + CommandTestCase( + "w_tagged_non_numeric", + command={ + "updates": [{"q": {}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": {"dc1": "hello"}}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="w tagged object with non-numeric value should be rejected.", + ), + CommandTestCase( + "w_int64_max", + command={"updates": [{"q": {}, "u": {"$set": {"a": 1}}}], "writeConcern": {"w": INT64_MAX}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="w as Int64 max should be rejected.", + ), + CommandTestCase( + "w_int64_min", + command={"updates": [{"q": {}, "u": {"$set": {"a": 1}}}], "writeConcern": {"w": INT64_MIN}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="w as Int64 min should be rejected.", + ), + CommandTestCase( + "w_tagged_empty_object", + command={"updates": [{"q": {}, "u": {"$set": {"a": 1}}}], "writeConcern": {"w": {}}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="empty object w should be rejected (tagged write concern requires tags).", + ), + CommandTestCase( + "w_tagged_nested_object", + command={ + "updates": [{"q": {}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": {"dc1": {"nested": 1}}}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="tagged w with nested object value should be rejected.", + ), + CommandTestCase( + "wtimeout_int64_overflow", + command={ + "updates": [{"q": {}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": 1, "wtimeout": Int64(INT32_MAX + 1)}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="Int64 wtimeout exceeding INT32_MAX should be rejected.", + ), + CommandTestCase( + "wtimeout_double_overflow", + command={ + "updates": [{"q": {}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": 1, "wtimeout": float(INT32_OVERFLOW)}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="double wtimeout exceeding INT32_MAX should be rejected.", + ), + CommandTestCase( + "wtimeout_decimal128_overflow", + command={ + "updates": [{"q": {}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": 1, "wtimeout": Decimal128(str(INT32_OVERFLOW))}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="Decimal128 wtimeout exceeding INT32_MAX should be rejected.", + ), + CommandTestCase( + "wtimeout_float_infinity", + command={ + "updates": [{"q": {}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": 1, "wtimeout": FLOAT_INFINITY}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="+Infinity wtimeout (exceeds INT32_MAX) should be rejected.", + ), + # Property [Unknown Field Rejection]: unrecognized fields in writeConcern are rejected. + CommandTestCase( + "unknown_field", + command={ + "updates": [{"q": {}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": 1, "unknownField": 1}, + }, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="writeConcern should reject unrecognized fields.", + ), + CommandTestCase( + "provenance_invalid_string", + command={ + "updates": [{"q": {}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": 1, "provenance": "invalid"}, + }, + error_code=BAD_VALUE_ERROR, + msg="provenance with an unknown string value should be rejected.", + ), + CommandTestCase( + "provenance_wrong_type", + command={ + "updates": [{"q": {}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": 1, "provenance": 42}, + }, + error_code=TYPE_MISMATCH_ERROR, + msg="provenance with a non-string value should be rejected.", + ), + # A valid writeConcern (w:1) surfaces a per-operation error: multi:true with a + # replacement doc is invalid. The w:0 "suppresses" counterpart lives in + # test_write_concern_behavior.py (w0_suppresses_operation_error). + CommandTestCase( + "w1_surfaces_operation_error", + docs=[{"_id": 1, "a": 1}], + command={ + "updates": [{"q": {}, "u": {"a": 2}, "multi": True}], + "writeConcern": {"w": 1}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="update with w:1 should surface the operation error.", + ), + # w "majority" is case-sensitive and an empty string is a custom tag; both are + # only rejected on standalone (see _STANDALONE_ONLY). + CommandTestCase( + "w_wrong_case_Majority", + command={ + "updates": [{"q": {}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": "Majority"}, + }, + error_code=BAD_VALUE_ERROR, + msg="w:'Majority' should be rejected (case-sensitive).", + marks=_STANDALONE_ONLY, + ), + CommandTestCase( + "w_all_caps_MAJORITY", + command={ + "updates": [{"q": {}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": "MAJORITY"}, + }, + error_code=BAD_VALUE_ERROR, + msg="w:'MAJORITY' should be rejected (case-sensitive).", + marks=_STANDALONE_ONLY, + ), + CommandTestCase( + "w_empty_string", + command={"updates": [{"q": {}, "u": {"$set": {"a": 1}}}], "writeConcern": {"w": ""}}, + error_code=BAD_VALUE_ERROR, + msg="w:'' (empty string custom tag) should be rejected on standalone.", + marks=_STANDALONE_ONLY, + ), +] + + +@pytest.mark.parametrize("test", pytest_params(WRITE_CONCERN_REJECTION_TESTS)) +def test_write_concern_rejected(collection, test: CommandTestCase): + """Test writeConcern rejects invalid sub-field values and unknown fields.""" + collection = test.prepare(collection.database, collection) + update_body = cast(Dict[str, Any], test.command) + result = execute_command(collection, {"update": collection.name, **update_body}) + assertResult(result, error_code=test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_write_concern_replica_set.py b/documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_write_concern_replica_set.py new file mode 100644 index 000000000..b8d181115 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_write_concern_replica_set.py @@ -0,0 +1,53 @@ +"""writeConcern w values > 1 (up to 50), accepted only on a replica set. +Deselected on standalone targets. +""" + +from typing import Any, Dict, cast + +import pytest +from bson import Int64 + +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_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq + +pytestmark = pytest.mark.requires(quorum_write_concern=True) + + +W_REPLICA_SET_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "w_2", + command={"updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], "writeConcern": {"w": 2}}, + expected={"ok": Eq(1.0)}, + msg="w:2 should be accepted on a replica set.", + ), + CommandTestCase( + "w_50_max", + command={ + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": 50}, + }, + expected={"ok": Eq(1.0)}, + msg="w:50 (max) should be accepted on a replica set.", + ), + CommandTestCase( + "w_int64_50", + command={ + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": Int64(50)}, + }, + expected={"ok": Eq(1.0)}, + msg="w as Int64(50) should be accepted on a replica set.", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(W_REPLICA_SET_TESTS)) +def test_write_concern_w_replica_set(collection, test: CommandTestCase): + """Test writeConcern accepts w values requiring a replica set.""" + collection.insert_one({"_id": 1, "a": 0}) + update_body = cast(Dict[str, Any], test.command) + result = execute_command(collection, {"update": collection.name, **update_body}) + assertResult(result, expected=test.expected, msg=test.msg, raw_res=True) diff --git a/documentdb_tests/compatibility/tests/core/query_planning/commands/removeQuerySettings/__init__.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/removeQuerySettings/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/query_planning/commands/removeQuerySettings/test_removeQuerySettings_behavior.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/removeQuerySettings/test_removeQuerySettings_behavior.py new file mode 100644 index 000000000..e98715a80 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_planning/commands/removeQuerySettings/test_removeQuerySettings_behavior.py @@ -0,0 +1,584 @@ +"""Tests for removeQuerySettings command behavioral verification. + +Verifies that removeQuerySettings actually removes query settings from the +cluster, not just that it returns ok: 1.0. Uses $querySettings to observe +settings state before and after removal. +""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from documentdb_tests.compatibility.tests.core.query_planning.utils.settings_test_case import ( + SettingsTestCase, +) +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, +) +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.requires(cluster_admin=True), pytest.mark.no_parallel] + +# Property [Remove By Query Shape]: removeQuerySettings removes settings +# when given the original query shape, verified via $querySettings. +# Property [Remove By Hash]: removeQuerySettings removes settings when given +# the query shape hash string, verified via $querySettings. +# Property [Remove Distinct Shape]: removeQuerySettings removes settings for +# distinct query shapes, verified via $querySettings. +# Property [Remove Aggregate Shape]: removeQuerySettings removes settings for +# aggregate query shapes, verified via $querySettings. +# Property [Shape Matching Ignores Filter Values]: query shape matching uses +# field structure, not values. Removing with different filter values removes +# the original setting. +REMOVEQUERYSETTINGS_SETTING_REMOVED_TESTS: list[SettingsTestCase] = [ + SettingsTestCase( + "removes_by_query_shape", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"r1": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + } + ], + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"r1": 1}, + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"r1": 1}, + "$db": ctx.database, + } + } + ], + msg="removeQuerySettings should remove the setting by query shape", + ), + SettingsTestCase( + "removes_by_hash", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"r2": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + } + ], + command=lambda ctx: {"removeQuerySettings": ctx.setup_results[0]["queryShapeHash"]}, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"r2": 1}, + "$db": ctx.database, + } + } + ], + msg="removeQuerySettings should remove the setting by hash", + ), + SettingsTestCase( + "removes_distinct_shape", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "distinct": ctx.collection, + "key": "x", + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + } + ], + command=lambda ctx: { + "removeQuerySettings": { + "distinct": ctx.collection, + "key": "x", + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "distinct": ctx.collection, + "key": "x", + "$db": ctx.database, + } + } + ], + msg="removeQuerySettings should remove the distinct setting", + ), + SettingsTestCase( + "removes_aggregate_shape", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "aggregate": ctx.collection, + "pipeline": [{"$match": {"x": 1}}], + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + } + ], + command=lambda ctx: { + "removeQuerySettings": { + "aggregate": ctx.collection, + "pipeline": [{"$match": {"x": 1}}], + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "aggregate": ctx.collection, + "pipeline": [{"$match": {"x": 1}}], + "$db": ctx.database, + } + } + ], + msg="removeQuerySettings should remove the aggregate setting", + ), + SettingsTestCase( + "shape_ignores_filter_values", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"sm1": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + } + ], + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"sm1": 999}, + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"sm1": 1}, + "$db": ctx.database, + } + } + ], + msg="shape matching should ignore filter values and remove the setting", + ), +] + + +@pytest.mark.admin +@pytest.mark.parametrize("test", pytest_params(REMOVEQUERYSETTINGS_SETTING_REMOVED_TESTS)) +def test_removeQuerySettings_setting_removed(collection, test): + """Test that removeQuerySettings actually removes settings, verified via $querySettings.""" + ctx = CommandContext.from_collection(collection) + try: + for cmd in test.build_setup(ctx): + r = execute_admin_command(collection, cmd) + ctx.setup_results.append(r) + expected_hash = ctx.setup_results[0]["queryShapeHash"] + + execute_admin_command(collection, test.build_command(ctx)) + + admin = collection.database.client.admin + qs_result = admin.command( + {"aggregate": 1, "pipeline": [{"$querySettings": {}}], "cursor": {}} + ) + batch: list[dict[str, Any]] = qs_result.get("cursor", {}).get("firstBatch", []) + count = sum(1 for s in batch if s.get("queryShapeHash") == expected_hash) + assertSuccessPartial( + {"count": count}, + {"count": 0}, + msg=test.msg, + ) + finally: + for cmd in test.build_cleanup(ctx): + try: + execute_admin_command(collection, cmd) + except Exception: + pass + + +# Property [Idempotent Removal]: calling removeQuerySettings a second time +# for the same query shape succeeds silently without error. +REMOVEQUERYSETTINGS_IDEMPOTENT_TESTS: list[SettingsTestCase] = [ + SettingsTestCase( + "idempotent_removal", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"r3": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"r3": 1}, + "$db": ctx.database, + } + }, + ], + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"r3": 1}, + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"r3": 1}, + "$db": ctx.database, + } + } + ], + msg="removeQuerySettings should succeed silently on second removal", + ), +] + + +@pytest.mark.admin +@pytest.mark.parametrize("test", pytest_params(REMOVEQUERYSETTINGS_IDEMPOTENT_TESTS)) +def test_removeQuerySettings_idempotent(collection, test): + """Test removeQuerySettings is idempotent on second call.""" + ctx = CommandContext.from_collection(collection) + try: + for cmd in test.build_setup(ctx): + r = execute_admin_command(collection, cmd) + ctx.setup_results.append(r) + result = execute_admin_command(collection, test.build_command(ctx)) + assertSuccessPartial(result, test.build_expected(ctx), msg=test.msg) + finally: + for cmd in test.build_cleanup(ctx): + try: + execute_admin_command(collection, cmd) + except Exception: + pass + + +# Property [Shape Matching Includes Collection]: collection name is part of +# the query shape. Removing with a different collection does not affect the +# original setting. +# Property [Shape Matching Includes $db]: $db is part of the query shape. +# Removing with a different $db does not affect the original setting. +# Property [Shape Matching Includes Sort Direction]: sort direction is part +# of the query shape. Removing with a different sort direction does not +# affect the original setting. +# Property [Shape Matching Includes Extra Fields]: adding extra fields +# changes the query shape. Removing with extra fields does not affect the +# original filter-only setting. +REMOVEQUERYSETTINGS_SHAPE_PERSISTS_TESTS: list[SettingsTestCase] = [ + SettingsTestCase( + "shape_collection_name_matters", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"sm2": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + } + ], + command=lambda ctx: { + "removeQuerySettings": { + "find": "other_collection", + "filter": {"sm2": 1}, + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"sm2": 1}, + "$db": ctx.database, + } + } + ], + msg="removing with different collection should not affect original setting", + ), + SettingsTestCase( + "shape_db_matters", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"sm3": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + } + ], + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"sm3": 1}, + "$db": "other_database", + } + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"sm3": 1}, + "$db": ctx.database, + } + } + ], + msg="removing with different $db should not affect original setting", + ), + SettingsTestCase( + "shape_sort_direction_matters", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"sm4": 1}, + "sort": {"sm4": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + } + ], + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"sm4": 1}, + "sort": {"sm4": -1}, + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"sm4": 1}, + "sort": {"sm4": 1}, + "$db": ctx.database, + } + } + ], + msg="removing with different sort direction should not affect original setting", + ), + SettingsTestCase( + "shape_extra_fields_change_shape", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"sm5": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + } + ], + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"sm5": 1}, + "sort": {"sm5": 1}, + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"sm5": 1}, + "$db": ctx.database, + } + } + ], + msg="removing with extra fields should not affect filter-only setting", + ), +] + + +@pytest.mark.admin +@pytest.mark.parametrize("test", pytest_params(REMOVEQUERYSETTINGS_SHAPE_PERSISTS_TESTS)) +def test_removeQuerySettings_shape_persists(collection, test): + """Test that mismatched shapes do not remove original settings.""" + ctx = CommandContext.from_collection(collection) + try: + for cmd in test.build_setup(ctx): + r = execute_admin_command(collection, cmd) + ctx.setup_results.append(r) + expected_hash = ctx.setup_results[0]["queryShapeHash"] + + execute_admin_command(collection, test.build_command(ctx)) + + admin = collection.database.client.admin + qs_result = admin.command( + {"aggregate": 1, "pipeline": [{"$querySettings": {}}], "cursor": {}} + ) + batch: list[dict[str, Any]] = qs_result.get("cursor", {}).get("firstBatch", []) + count = sum(1 for s in batch if s.get("queryShapeHash") == expected_hash) + assertSuccessPartial({"count": count}, {"count": 1}, msg=test.msg) + finally: + for cmd in test.build_cleanup(ctx): + try: + execute_admin_command(collection, cmd) + except Exception: + pass + + +# Property [Reject Removal Restores Query]: removing a reject: true setting +# allows the previously-rejected query to succeed again. +REMOVEQUERYSETTINGS_REJECT_REMOVAL_TESTS: list[SettingsTestCase] = [ + SettingsTestCase( + "reject_removal_restores_query", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"rj1": 1}, + "$db": ctx.database, + }, + "settings": {"reject": True}, + } + ], + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"rj1": 1}, + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"rj1": 1}, + "$db": ctx.database, + } + } + ], + msg="query should succeed after removing reject: true setting", + ), +] + + +@pytest.mark.admin +@pytest.mark.parametrize("test", pytest_params(REMOVEQUERYSETTINGS_REJECT_REMOVAL_TESTS)) +def test_removeQuerySettings_reject_removal(collection, test): + """Test that removing reject: true setting restores the query.""" + ctx = CommandContext.from_collection(collection) + try: + for cmd in test.build_setup(ctx): + r = execute_admin_command(collection, cmd) + ctx.setup_results.append(r) + + # Remove the reject setting + execute_admin_command(collection, test.build_command(ctx)) + + # Verify query succeeds after removal + restored = execute_command(collection, {"find": ctx.collection, "filter": {"rj1": 1}}) + assertSuccessPartial(restored, {"ok": 1.0}, msg=test.msg) + finally: + for cmd in test.build_cleanup(ctx): + try: + execute_admin_command(collection, cmd) + except Exception: + pass diff --git a/documentdb_tests/compatibility/tests/core/query_planning/commands/removeQuerySettings/test_removeQuerySettings_core.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/removeQuerySettings/test_removeQuerySettings_core.py new file mode 100644 index 000000000..0eff29d65 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_planning/commands/removeQuerySettings/test_removeQuerySettings_core.py @@ -0,0 +1,311 @@ +"""Tests for removeQuerySettings command core acceptance behavior. + +Validates that the removeQuerySettings command accepts valid query shapes +for find, distinct, and aggregate commands, various shape variations, +$db field variations, hash-based removal, and idempotent behavior. +""" + +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 assertSuccessPartial +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = [pytest.mark.requires(cluster_admin=True), pytest.mark.no_parallel] + +# Property [Find Shape Acceptance]: removeQuerySettings accepts find shapes +# with various field combinations without error. +REMOVEQUERYSETTINGS_FIND_ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "accepts_find_filter_only", + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"a": 1}, + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + msg="removeQuerySettings should accept find with filter only", + ), + CommandTestCase( + "accepts_find_filter_sort", + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"b": 1}, + "sort": {"b": 1}, + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + msg="removeQuerySettings should accept find with filter and sort", + ), + CommandTestCase( + "accepts_find_filter_projection", + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"c": 1}, + "projection": {"c": 1}, + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + msg="removeQuerySettings should accept find with filter and projection", + ), + CommandTestCase( + "accepts_find_filter_sort_projection", + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"d": 1}, + "sort": {"d": 1}, + "projection": {"d": 1}, + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + msg="removeQuerySettings should accept find with all shape fields", + ), + CommandTestCase( + "accepts_find_with_collation", + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"e": "abc"}, + "collation": {"locale": "en", "strength": 2}, + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + msg="removeQuerySettings should accept find with collation", + ), + CommandTestCase( + "accepts_find_with_let", + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"$expr": {"$eq": ["$f", "$$target"]}}, + "let": {"target": 1}, + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + msg="removeQuerySettings should accept find with let", + ), + CommandTestCase( + "accepts_find_without_filter", + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + msg="removeQuerySettings should accept find without filter", + ), +] + +# Property [Distinct Shape Acceptance]: removeQuerySettings accepts distinct +# shapes with various field combinations without error. +REMOVEQUERYSETTINGS_DISTINCT_ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "accepts_distinct_key_only", + command=lambda ctx: { + "removeQuerySettings": { + "distinct": ctx.collection, + "key": "j", + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + msg="removeQuerySettings should accept distinct with key only", + ), + CommandTestCase( + "accepts_distinct_key_with_query", + command=lambda ctx: { + "removeQuerySettings": { + "distinct": ctx.collection, + "key": "k", + "query": {"$and": [{"k": {"$gt": 0}}, {"k": {"$lt": 100}}]}, + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + msg="removeQuerySettings should accept distinct with query filter", + ), +] + +# Property [Aggregate Shape Acceptance]: removeQuerySettings accepts aggregate +# pipeline shapes with various stage combinations without error. +REMOVEQUERYSETTINGS_AGGREGATE_ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "accepts_aggregate_single_stage", + command=lambda ctx: { + "removeQuerySettings": { + "aggregate": ctx.collection, + "pipeline": [{"$match": {"l": 1}}], + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + msg="removeQuerySettings should accept aggregate with single stage", + ), + CommandTestCase( + "accepts_aggregate_multi_stage", + command=lambda ctx: { + "removeQuerySettings": { + "aggregate": ctx.collection, + "pipeline": [ + {"$match": {"m": 1}}, + {"$group": {"_id": "$m", "count": {"$sum": 1}}}, + ], + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + msg="removeQuerySettings should accept aggregate with multiple stages", + ), +] + +# Property [Nonexistent $db Acceptance]: removeQuerySettings accepts +# non-existent database names in the $db field without error. +REMOVEQUERYSETTINGS_DB_ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "accepts_nonexistent_db", + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"o": 1}, + "$db": f"{ctx.database}_nonexistent", + } + }, + expected={"ok": 1.0}, + msg="removeQuerySettings should accept non-existent $db", + ), +] + +# Property [Silent No-Op]: removeQuerySettings succeeds silently when +# no matching settings exist. +REMOVEQUERYSETTINGS_NOOP_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "noop_nonexistent_query_shape", + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"nonexistent_field": 1}, + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + msg="removeQuerySettings should succeed when no matching settings exist", + ), + CommandTestCase( + "noop_nonexistent_hash", + command=lambda ctx: { + "removeQuerySettings": "00000000000000000000000000000000" + "00000000000000000000000000000000" + }, + expected={"ok": 1.0}, + msg="removeQuerySettings should succeed with a non-existent hash", + ), + CommandTestCase( + "noop_lowercase_hash", + command=lambda ctx: { + "removeQuerySettings": "abcdef0123456789abcdef0123456789" + "abcdef0123456789abcdef0123456789" + }, + expected={"ok": 1.0}, + msg="removeQuerySettings should accept lowercase hex hash", + ), +] + +# Property [IDHACK Query Acceptance]: unlike setQuerySettings which rejects +# IDHACK-eligible queries, removeQuerySettings accepts them. +REMOVEQUERYSETTINGS_IDHACK_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "accepts_idhack_query", + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"_id": 1}, + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + msg="removeQuerySettings should accept IDHACK-eligible queries", + ), +] + +# Property [Internal Database Acceptance]: unlike setQuerySettings which +# rejects internal databases, removeQuerySettings accepts them. +REMOVEQUERYSETTINGS_INTERNAL_DB_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "accepts_admin_db", + command=lambda ctx: { + "removeQuerySettings": { + "find": "system.users", + "filter": {}, + "$db": "admin", + } + }, + expected={"ok": 1.0}, + msg="removeQuerySettings should accept admin database query shapes", + ), + CommandTestCase( + "accepts_local_db", + command=lambda ctx: { + "removeQuerySettings": { + "find": "oplog.rs", + "filter": {}, + "$db": "local", + } + }, + expected={"ok": 1.0}, + msg="removeQuerySettings should accept local database query shapes", + ), +] + +# Property [Comment Field Acceptance]: removeQuerySettings accepts the +# comment top-level field without error. +REMOVEQUERYSETTINGS_COMMENT_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "accepts_comment_field", + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"cmt1": 1}, + "$db": ctx.database, + }, + "comment": "test comment", + }, + expected={"ok": 1.0}, + msg="removeQuerySettings should accept comment field", + ), +] + +REMOVEQUERYSETTINGS_CORE_TESTS: list[CommandTestCase] = ( + REMOVEQUERYSETTINGS_FIND_ACCEPTANCE_TESTS + + REMOVEQUERYSETTINGS_DISTINCT_ACCEPTANCE_TESTS + + REMOVEQUERYSETTINGS_AGGREGATE_ACCEPTANCE_TESTS + + REMOVEQUERYSETTINGS_DB_ACCEPTANCE_TESTS + + REMOVEQUERYSETTINGS_NOOP_TESTS + + REMOVEQUERYSETTINGS_IDHACK_TESTS + + REMOVEQUERYSETTINGS_INTERNAL_DB_TESTS + + REMOVEQUERYSETTINGS_COMMENT_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(REMOVEQUERYSETTINGS_CORE_TESTS)) +def test_removeQuerySettings_core(collection, test): + """Test removeQuerySettings command core acceptance behavior.""" + 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/core/query_planning/commands/removeQuerySettings/test_removeQuerySettings_error.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/removeQuerySettings/test_removeQuerySettings_error.py new file mode 100644 index 000000000..1a942cec6 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_planning/commands/removeQuerySettings/test_removeQuerySettings_error.py @@ -0,0 +1,212 @@ +"""Tests for removeQuerySettings command error cases. + +Validates that the removeQuerySettings command rejects invalid BSON types for +the primary argument, malformed query shapes, invalid hash strings, and +unrecognized top-level fields. +""" + +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, + INVALID_LENGTH_ERROR, + INVALID_NAMESPACE_ERROR, + MISSING_FIELD_ERROR, + QUERYSETTINGS_UNKNOWN_COMMAND_SHAPE_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 + +pytestmark = [pytest.mark.requires(cluster_admin=True), pytest.mark.no_parallel] + +# Property [Primary Argument Type Rejection]: the removeQuerySettings field +# must be a document or string. All other BSON types are rejected. +REMOVEQUERYSETTINGS_PRIMARY_ARG_TYPE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"primary_arg_{tid}", + command=lambda ctx, v=value: {"removeQuerySettings": v}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"removeQuerySettings should reject {tid} as the primary argument", + ) + for tid, value in [ + ("null", None), + ("int32", 42), + ("int64", Int64(42)), + ("double", 3.14), + ("decimal128", Decimal128("1")), + ("bool_true", True), + ("bool_false", False), + ("array", [1, 2, 3]), + ("objectid", ObjectId()), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(0, 0)), + ("binary", Binary(b"\x00")), + ("regex", Regex(".*")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Query Shape Validation]: rejects malformed query shape documents +# including empty documents, missing/empty/null $db, and unknown command types. +REMOVEQUERYSETTINGS_QUERY_SHAPE_VALIDATION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "query_shape_empty_document", + command=lambda ctx: {"removeQuerySettings": {}}, + error_code=QUERYSETTINGS_UNKNOWN_COMMAND_SHAPE_ERROR, + msg="removeQuerySettings should reject empty query shape document", + ), + CommandTestCase( + "query_shape_missing_db", + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"x": 1}, + } + }, + error_code=MISSING_FIELD_ERROR, + msg="removeQuerySettings should reject query shape missing $db field", + ), + CommandTestCase( + "query_shape_empty_db", + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"x": 1}, + "$db": "", + } + }, + error_code=INVALID_NAMESPACE_ERROR, + msg="removeQuerySettings should reject query shape with empty $db", + ), + CommandTestCase( + "query_shape_null_db", + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"x": 1}, + "$db": None, + } + }, + error_code=MISSING_FIELD_ERROR, + msg="removeQuerySettings should reject query shape with null $db", + ), + CommandTestCase( + "query_shape_unknown_command", + command=lambda ctx: { + "removeQuerySettings": { + "unknownCommand": ctx.collection, + "filter": {"x": 1}, + "$db": ctx.database, + } + }, + error_code=QUERYSETTINGS_UNKNOWN_COMMAND_SHAPE_ERROR, + msg="removeQuerySettings should reject unknown command type in query shape", + ), + CommandTestCase( + "query_shape_no_command_type", + command=lambda ctx: { + "removeQuerySettings": { + "filter": {"x": 1}, + "$db": ctx.database, + } + }, + error_code=QUERYSETTINGS_UNKNOWN_COMMAND_SHAPE_ERROR, + msg="removeQuerySettings should reject query shape without a command type", + ), +] + +# Property [Hash String Validation]: rejects invalid hash string formats +# including empty, too short, too long, and non-hexadecimal strings. +REMOVEQUERYSETTINGS_HASH_VALIDATION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "empty_hash_string", + command=lambda ctx: {"removeQuerySettings": ""}, + error_code=INVALID_LENGTH_ERROR, + msg="removeQuerySettings should reject empty hash string", + ), + CommandTestCase( + "short_hash_string", + command=lambda ctx: {"removeQuerySettings": "ABCD"}, + error_code=INVALID_LENGTH_ERROR, + msg="removeQuerySettings should reject short hash string", + ), + CommandTestCase( + "long_hash_string", + command=lambda ctx: {"removeQuerySettings": "AA" * 33}, + error_code=INVALID_LENGTH_ERROR, + msg="removeQuerySettings should reject hash string longer than 64 chars", + ), + CommandTestCase( + "non_hex_hash_string", + command=lambda ctx: { + "removeQuerySettings": "GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG" + "GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG" + }, + error_code=BAD_VALUE_ERROR, + msg="removeQuerySettings should reject non-hex hash string", + ), +] + +# Property [Unrecognized Fields]: rejects unknown top-level command fields +# and fields valid for setQuerySettings but not removeQuerySettings. +REMOVEQUERYSETTINGS_UNRECOGNIZED_FIELD_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "unrecognized_top_level_field", + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"x": 1}, + "$db": ctx.database, + }, + "unknownField": 1, + }, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="removeQuerySettings should reject unrecognized top-level field", + ), + CommandTestCase( + "settings_field_rejected", + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"x": 1}, + "$db": ctx.database, + }, + "settings": {"reject": True}, + }, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="removeQuerySettings should reject settings field", + ), +] + +REMOVEQUERYSETTINGS_ERROR_TESTS: list[CommandTestCase] = ( + REMOVEQUERYSETTINGS_PRIMARY_ARG_TYPE_TESTS + + REMOVEQUERYSETTINGS_QUERY_SHAPE_VALIDATION_TESTS + + REMOVEQUERYSETTINGS_HASH_VALIDATION_TESTS + + REMOVEQUERYSETTINGS_UNRECOGNIZED_FIELD_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(REMOVEQUERYSETTINGS_ERROR_TESTS)) +def test_removeQuerySettings_error(collection, test): + """Test removeQuerySettings error cases.""" + ctx = CommandContext.from_collection(collection) + result = execute_admin_command(collection, test.build_command(ctx)) + assertResult( + result, + error_code=test.error_code, + msg=test.msg, + ) diff --git a/documentdb_tests/compatibility/tests/system/__init__.py b/documentdb_tests/compatibility/tests/system/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/system/administration/__init__.py b/documentdb_tests/compatibility/tests/system/administration/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/__init__.py b/documentdb_tests/compatibility/tests/system/administration/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/__init__.py b/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/__init__.py new file mode 100644 index 000000000..e69de29bb 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 new file mode 100644 index 000000000..52372da22 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_autoCompact_fstmb_bounds.py @@ -0,0 +1,295 @@ +"""Tests for autoCompact freeSpaceTargetMB numeric coercion and the lower bound.""" + +from __future__ import annotations + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.compatibility.tests.system.administration.commands.autoCompact.utils.autoCompact_common import ( # noqa: E501 + 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.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq +from documentdb_tests.framework.test_constants import ( + DECIMAL128_HALF, + DECIMAL128_INT64_UNDERFLOW, + DECIMAL128_JUST_ABOVE_HALF, + DECIMAL128_JUST_BELOW_HALF, + DECIMAL128_MAX_NEGATIVE, + DECIMAL128_MIN, + DECIMAL128_MIN_POSITIVE, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_HALF, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ONE_AND_HALF, + DECIMAL128_TRAILING_ZERO, + DECIMAL128_ZERO, + DOUBLE_MIN_SUBNORMAL, + DOUBLE_NEGATIVE_HALF, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_ONE_AND_HALF, + DOUBLE_ZERO, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + INT32_MAX, + INT32_MIN, + INT32_OVERFLOW, + INT64_MIN, +) + +# Property [freeSpaceTargetMB Accepted Values]: a freeSpaceTargetMB whose +# coerced value is >= 1 is accepted across all numeric BSON types and across the +# int32/int64 boundary. +AUTOCOMPACT_FSTMB_ACCEPTED_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "fstmb_min_one", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": 1}, + expected={"ok": Eq(1.0)}, + msg="autoCompact should accept the minimum freeSpaceTargetMB of 1", + ), + CommandTestCase( + "fstmb_type_double", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": 1.0}, + expected={"ok": Eq(1.0)}, + msg="autoCompact should accept a double freeSpaceTargetMB", + ), + CommandTestCase( + "fstmb_type_long", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": Int64(1)}, + expected={"ok": Eq(1.0)}, + msg="autoCompact should accept a long freeSpaceTargetMB", + ), + CommandTestCase( + "fstmb_type_decimal", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": Decimal128("1")}, + expected={"ok": Eq(1.0)}, + msg="autoCompact should accept a decimal freeSpaceTargetMB", + ), + CommandTestCase( + "fstmb_int32_max", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": INT32_MAX}, + expected={"ok": Eq(1.0)}, + msg="autoCompact should accept int32 max as freeSpaceTargetMB", + ), + CommandTestCase( + "fstmb_above_int32_max", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": Int64(INT32_OVERFLOW)}, + expected={"ok": Eq(1.0)}, + msg="autoCompact should accept a long just above int32 max as freeSpaceTargetMB", + ), + CommandTestCase( + "fstmb_decimal_trailing_zero", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": DECIMAL128_TRAILING_ZERO}, + expected={"ok": Eq(1.0)}, + msg="autoCompact should accept a trailing-zero decimal freeSpaceTargetMB coercing to 1", + ), +] + +# Property [freeSpaceTargetMB Fractional Coercion]: a fractional +# freeSpaceTargetMB is coerced to an integer before validation: doubles +# truncate toward zero and decimals round half-to-even. +AUTOCOMPACT_FSTMB_FRACTIONAL_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "fstmb_double_one_and_half", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": DOUBLE_ONE_AND_HALF}, + expected={"ok": Eq(1.0)}, + msg="autoCompact should truncate a double freeSpaceTargetMB toward zero to an accepted 1", + ), + CommandTestCase( + "fstmb_decimal_just_above_half_full_precision", + command=lambda ctx: { + "autoCompact": True, + "freeSpaceTargetMB": DECIMAL128_JUST_ABOVE_HALF, + }, + expected={"ok": Eq(1.0)}, + msg="autoCompact should round a 34-digit just-above-half decimal up to an accepted 1", + ), + CommandTestCase( + "fstmb_decimal_one_and_half", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": DECIMAL128_ONE_AND_HALF}, + expected={"ok": Eq(1.0)}, + msg="autoCompact should round a decimal half-value half-to-even up to an accepted 2", + ), +] + +# Property [freeSpaceTargetMB Value Validation - Lower Bound]: a +# freeSpaceTargetMB whose coerced integer value is below 1 produces a bad-value +# error. +AUTOCOMPACT_FSTMB_LOWER_BOUND_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "lower_bound_int_zero", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": 0}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should reject an int freeSpaceTargetMB of 0 as below the lower bound", + ), + CommandTestCase( + "lower_bound_int32_min", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": INT32_MIN}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should reject int32 min freeSpaceTargetMB as below the lower bound", + ), + CommandTestCase( + "lower_bound_int64_min", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": INT64_MIN}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should reject int64 min freeSpaceTargetMB as below the lower bound", + ), + CommandTestCase( + "lower_bound_double_zero", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": DOUBLE_ZERO}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should reject a double 0.0 freeSpaceTargetMB as below the lower bound", + ), + CommandTestCase( + "lower_bound_double_negative_zero", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": DOUBLE_NEGATIVE_ZERO}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should coerce double -0.0 to 0 and reject it as below the lower bound", + ), + CommandTestCase( + "lower_bound_decimal_zero", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": DECIMAL128_ZERO}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should reject a decimal 0 freeSpaceTargetMB as below the lower bound", + ), + CommandTestCase( + "lower_bound_decimal_negative_zero", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": DECIMAL128_NEGATIVE_ZERO}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should coerce decimal -0 to 0 and reject it as below the lower bound", + ), + CommandTestCase( + "lower_bound_double_near_one", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": 0.999999}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should truncate a double just below 1 to 0 and reject it as below bound", + ), + CommandTestCase( + "lower_bound_double_negative_half", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": DOUBLE_NEGATIVE_HALF}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should truncate double -0.5 to 0 and reject it as below the lower bound", + ), + CommandTestCase( + "lower_bound_decimal_half", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": DECIMAL128_HALF}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should round decimal 0.5 half-to-even to 0 and reject it below the bound", + ), + CommandTestCase( + "lower_bound_decimal_negative_half", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": DECIMAL128_NEGATIVE_HALF}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should round decimal -0.5 half-to-even to 0 and reject it below the bound", + ), + CommandTestCase( + "lower_bound_double_min_subnormal", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": DOUBLE_MIN_SUBNORMAL}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should truncate the min subnormal double to 0 and reject it below bound", + ), + CommandTestCase( + "lower_bound_decimal_min_positive", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": DECIMAL128_MIN_POSITIVE}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should coerce the smallest positive decimal to 0 and reject it below " + "bound", + ), + CommandTestCase( + "lower_bound_decimal_max_negative", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": DECIMAL128_MAX_NEGATIVE}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should coerce the smallest negative decimal to 0 and reject it below " + "bound", + ), + CommandTestCase( + "lower_bound_decimal_just_below_half", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": DECIMAL128_JUST_BELOW_HALF}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should round a 34-digit just-below-half decimal to 0 and reject it below " + "bound", + ), + CommandTestCase( + "lower_bound_decimal_int64_underflow", + command=lambda ctx: { + "autoCompact": True, + "freeSpaceTargetMB": DECIMAL128_INT64_UNDERFLOW, + }, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should saturate a below-int64-min decimal to int64 min and reject it " + "below bound", + ), + CommandTestCase( + "lower_bound_decimal_min", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": DECIMAL128_MIN}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should saturate a far-negative decimal to int64 min and reject it below " + "bound", + ), + CommandTestCase( + "lower_bound_decimal_min_disable", + command=lambda ctx: {"autoCompact": False, "freeSpaceTargetMB": DECIMAL128_MIN}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact disable should still enforce the lower bound on a far-negative decimal", + ), + CommandTestCase( + "lower_bound_double_nan", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": FLOAT_NAN}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should coerce double NaN to 0 and reject it as below the lower bound", + ), + CommandTestCase( + "lower_bound_double_negative_infinity", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": FLOAT_NEGATIVE_INFINITY}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should saturate double -Infinity to int64 min and reject it below bound", + ), + CommandTestCase( + "lower_bound_decimal_nan", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": DECIMAL128_NAN}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should coerce decimal NaN to 0 and reject it as below the lower bound", + ), + CommandTestCase( + "lower_bound_decimal_negative_infinity", + command=lambda ctx: { + "autoCompact": True, + "freeSpaceTargetMB": DECIMAL128_NEGATIVE_INFINITY, + }, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should saturate decimal -Infinity to int64 min and reject it below bound", + ), +] + +AUTOCOMPACT_FSTMB_BOUNDS_TESTS: list[CommandTestCase] = ( + AUTOCOMPACT_FSTMB_ACCEPTED_TESTS + + AUTOCOMPACT_FSTMB_FRACTIONAL_TESTS + + AUTOCOMPACT_FSTMB_LOWER_BOUND_TESTS +) + + +@pytest.mark.no_parallel +@pytest.mark.parametrize("test", pytest_params(AUTOCOMPACT_FSTMB_BOUNDS_TESTS)) +def test_autoCompact_fstmb_bounds(database_client, collection, test): + """Test autoCompact freeSpaceTargetMB coercion and lower-bound enforcement.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + # 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)) + 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/autoCompact/test_autoCompact_fstmb_overflow.py b/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_autoCompact_fstmb_overflow.py new file mode 100644 index 000000000..b9d96d4fb --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_autoCompact_fstmb_overflow.py @@ -0,0 +1,169 @@ +"""Tests for the autoCompact freeSpaceTargetMB MB-to-bytes overflow boundary.""" + +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.compatibility.tests.system.administration.commands.autoCompact.utils.autoCompact_common import ( # noqa: E501 + 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.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_INT64_OVERFLOW, + DECIMAL128_MAX, + DOUBLE_FROM_INT64_MAX, + DOUBLE_MAX, + DOUBLE_MAX_SAFE_INTEGER, + FLOAT_INFINITY, + INT64_MAX, +) + +# freeSpaceTargetMB is converted to bytes as a signed int64 (value * 2^20), so +# the largest value whose byte product still fits is INT64_MAX // 2^20 and the +# smallest value that overflows to a negative signed int64 is one past it. +_MB_IN_BYTES = 1 << 20 +_FSTMB_BYTE_OVERFLOW = INT64_MAX // _MB_IN_BYTES + 1 + +# Property [freeSpaceTargetMB Enable/Disable Path Asymmetry]: the MB-to-bytes +# overflow check runs only on the enable path, so a freeSpaceTargetMB whose byte +# product overflows signed int64 is accepted when disabling. +AUTOCOMPACT_FSTMB_PATH_ASYMMETRY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "path_disable_overflow", + command=lambda ctx: { + "autoCompact": False, + "freeSpaceTargetMB": Int64(_FSTMB_BYTE_OVERFLOW), + }, + expected={"ok": Eq(1.0)}, + msg="autoCompact disable should accept a freeSpaceTargetMB whose byte product overflows", + ), +] + +# Property [freeSpaceTargetMB Value Validation - Byte-Level Minimum]: a +# freeSpaceTargetMB that passes the lower bound but whose byte conversion wraps +# back to zero, below the byte minimum, is still rejected. Unlike the overflow +# cases the wrapped product is non-negative, so an implementation guarding only +# against a negative wrap would wrongly accept it; this region needs separate +# coverage. +AUTOCOMPACT_FSTMB_BYTE_MINIMUM_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "byte_minimum_double_max_safe_integer", + command=lambda ctx: { + "autoCompact": True, + "freeSpaceTargetMB": float(DOUBLE_MAX_SAFE_INTEGER), + }, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should reject a freeSpaceTargetMB whose byte product wraps to zero", + ), +] + +# Property [freeSpaceTargetMB Overflow Boundary]: the largest freeSpaceTargetMB +# whose byte product still fits in signed int64 is accepted on the enable path. +AUTOCOMPACT_FSTMB_OVERFLOW_ACCEPTED_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "overflow_accepted_boundary", + command=lambda ctx: { + "autoCompact": True, + "freeSpaceTargetMB": Int64(_FSTMB_BYTE_OVERFLOW - 1), + }, + expected={"ok": Eq(1.0)}, + msg="autoCompact should accept the largest freeSpaceTargetMB whose byte product stays " + "positive", + ), +] + +# Property [freeSpaceTargetMB Value Validation - Overflow]: on the enable path, +# a freeSpaceTargetMB whose MB-to-bytes product wraps to a negative signed int64 +# produces a bad-value error. +AUTOCOMPACT_FSTMB_OVERFLOW_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "overflow_boundary_plus_one", + command=lambda ctx: { + "autoCompact": True, + "freeSpaceTargetMB": Int64(_FSTMB_BYTE_OVERFLOW), + }, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should reject the smallest freeSpaceTargetMB whose byte product wraps " + "negative", + ), + CommandTestCase( + "overflow_int64_max", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": INT64_MAX}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should reject int64 max freeSpaceTargetMB whose byte product overflows", + ), + CommandTestCase( + "overflow_double_from_int64_max", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": DOUBLE_FROM_INT64_MAX}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should reject the int64-max-as-double freeSpaceTargetMB in the overflow " + "region", + ), + CommandTestCase( + "overflow_double_max", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": DOUBLE_MAX}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should reject the max double freeSpaceTargetMB in the overflow region", + ), + CommandTestCase( + "overflow_double_infinity", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": FLOAT_INFINITY}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should reject double +Infinity freeSpaceTargetMB in the overflow region", + ), + CommandTestCase( + "overflow_decimal_max", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": DECIMAL128_MAX}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should reject the max decimal freeSpaceTargetMB in the overflow region", + ), + CommandTestCase( + "overflow_decimal_int64_overflow", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": DECIMAL128_INT64_OVERFLOW}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should absorb an above-int64-max decimal freeSpaceTargetMB into the " + "overflow path", + ), + CommandTestCase( + "overflow_decimal_infinity", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": DECIMAL128_INFINITY}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should reject decimal Infinity freeSpaceTargetMB in the overflow region", + ), +] + +AUTOCOMPACT_FSTMB_OVERFLOW_BOUNDARY_TESTS: list[CommandTestCase] = ( + AUTOCOMPACT_FSTMB_PATH_ASYMMETRY_TESTS + + AUTOCOMPACT_FSTMB_BYTE_MINIMUM_TESTS + + AUTOCOMPACT_FSTMB_OVERFLOW_ACCEPTED_TESTS + + AUTOCOMPACT_FSTMB_OVERFLOW_TESTS +) + + +@pytest.mark.no_parallel +@pytest.mark.parametrize("test", pytest_params(AUTOCOMPACT_FSTMB_OVERFLOW_BOUNDARY_TESTS)) +def test_autoCompact_fstmb_overflow(database_client, collection, test): + """Test autoCompact freeSpaceTargetMB MB-to-bytes overflow boundary behavior.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + # 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)) + 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/autoCompact/test_autoCompact_operational.py b/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_autoCompact_operational.py new file mode 100644 index 000000000..bd7a8bde0 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_autoCompact_operational.py @@ -0,0 +1,104 @@ +"""Tests for autoCompact operational constraints: admin scope and reconfigure conflict.""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.compatibility.tests.system.administration.commands.autoCompact.utils.autoCompact_common import ( # noqa: E501 + ensure_autocompact_idle, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + CONFLICTING_OPERATION_IN_PROGRESS_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.property_checks import Eq + +# Property [Admin-Scope Errors]: a fully valid autoCompact run against a +# non-admin database is rejected as out of scope. +AUTOCOMPACT_ADMIN_SCOPE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "admin_scope_enable", + command=lambda ctx: {"autoCompact": True}, + error_code=UNAUTHORIZED_ERROR, + msg="autoCompact enable should be rejected when run against a non-admin database", + ), + CommandTestCase( + "admin_scope_disable", + command=lambda ctx: {"autoCompact": False}, + error_code=UNAUTHORIZED_ERROR, + msg="autoCompact disable should be rejected when run against a non-admin database", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(AUTOCOMPACT_ADMIN_SCOPE_TESTS)) +def test_autoCompact_admin_scope(database_client, collection, test): + """Test autoCompact admin-scope rejection against a non-admin database.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + # Run against the fixture's non-admin database so the admin-scope check can + # fire. It happens at dispatch, independent of compaction state, so no + # settling is needed here. + 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 [Running-State Reconfigure Conflict]: reconfiguring an enabled +# autoCompact with a different config is rejected with a conflict error rather +# than silently overriding the running config. +@pytest.mark.no_parallel +def test_autoCompact_reconfigure_conflict(collection): + """Test autoCompact rejects a differing reconfigure while enabled.""" + ensure_autocompact_idle(collection) + + # Establish a running config. + execute_admin_command(collection, {"autoCompact": True, "freeSpaceTargetMB": 30}) + + # Second should be rejected as the value differs. + result = execute_admin_command(collection, {"autoCompact": True, "freeSpaceTargetMB": 50}) + + assertResult( + result, + error_code=CONFLICTING_OPERATION_IN_PROGRESS_ERROR, + msg="autoCompact should reject reconfiguring an enabled compaction with a different config", + raw_res=True, + ) + + +# Property [Running-State Idempotent Reconfigure]: re-enabling an already +# enabled autoCompact with an identical config is accepted as a no-op rather +# than rejected, confirming the reconfigure conflict is driven by the config +# difference and not the already-running state. +@pytest.mark.no_parallel +def test_autoCompact_reconfigure_idempotent(collection): + """Test autoCompact accepts re-enabling an enabled compaction with an identical config.""" + ensure_autocompact_idle(collection) + + # Establish a running config. + execute_admin_command(collection, {"autoCompact": True, "freeSpaceTargetMB": 30}) + + # Second should be accepted as a no-op since the value is the same. + result = execute_admin_command(collection, {"autoCompact": True, "freeSpaceTargetMB": 30}) + + assertResult( + result, + expected={"ok": Eq(1.0)}, + msg="autoCompact should accept re-enabling an enabled compaction with an identical config", + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_autoCompact_request_validation.py b/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_autoCompact_request_validation.py new file mode 100644 index 000000000..4ed6528fc --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_autoCompact_request_validation.py @@ -0,0 +1,221 @@ +"""Tests for autoCompact request validation: type strictness, null, and bad fields.""" + +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.compatibility.tests.system.administration.commands.autoCompact.utils.autoCompact_common import ( # noqa: E501 + ensure_autocompact_idle, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + INVALID_OPTIONS_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 DOUBLE_ZERO, INT32_ZERO + +# Property [Null Command Value]: a null autoCompact command value is treated as +# a missing required field rather than a wrong type. +AUTOCOMPACT_NULL_COMMAND_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "null_command_value", + command=lambda ctx: {"autoCompact": None}, + error_code=MISSING_FIELD_ERROR, + msg="autoCompact should reject a null command value as a missing required field", + ), +] + +# Property [Value Type Strictness]: every non-bool BSON type for the autoCompact +# command value is rejected with a type mismatch error rather than coerced to a +# boolean, and a literal array is rejected without unwrapping. +AUTOCOMPACT_VALUE_TYPE_STRICTNESS_TESTS: list[CommandTestCase] = [ + *[ + CommandTestCase( + f"value_type_{tid}", + command=lambda ctx, v=val: {"autoCompact": v}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"autoCompact should reject a {tid} command value as the wrong type", + ) + for tid, val in [ + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.0), + ("decimal128", Decimal128("1")), + ("string", "true"), + ("object", {"a": 1}), + ("array", []), + ("objectid", ObjectId()), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01")), + ("regex", Regex(".*")), + ("code", Code("x")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] + ], + CommandTestCase( + "value_array_single_bool", + command=lambda ctx: {"autoCompact": [True]}, + error_code=TYPE_MISMATCH_ERROR, + msg="autoCompact should reject a single-element bool array without unwrapping it", + ), +] + +# Property [freeSpaceTargetMB Type Strictness]: every non-numeric BSON type for +# freeSpaceTargetMB is rejected with a type mismatch error rather than coerced +# to a number, and a literal array is rejected without unwrapping. +AUTOCOMPACT_FSTMB_TYPE_STRICTNESS_TESTS: list[CommandTestCase] = [ + *[ + CommandTestCase( + f"fstmb_type_{tid}", + command=lambda ctx, v=val: {"autoCompact": True, "freeSpaceTargetMB": v}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"autoCompact should reject a {tid} freeSpaceTargetMB as the wrong type", + ) + for tid, val in [ + ("string", "20"), + ("bool", True), + ("object", {"a": 1}), + ("array", []), + ("objectid", ObjectId()), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01")), + ("regex", Regex(".*")), + ("code", Code("x")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] + ], + CommandTestCase( + "fstmb_array_single_int", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": [20]}, + error_code=TYPE_MISMATCH_ERROR, + msg="autoCompact should reject a single-element int array freeSpaceTargetMB without unwrap", + ), +] + +# Property [runOnce Type Strictness]: every non-bool BSON type for runOnce is +# rejected with a type mismatch error rather than coerced to a boolean, so +# numeric 0 and 1 are not accepted. +AUTOCOMPACT_RUNONCE_TYPE_STRICTNESS_TESTS: list[CommandTestCase] = [ + *[ + CommandTestCase( + f"runonce_type_{tid}", + command=lambda ctx, v=val: {"autoCompact": True, "runOnce": v}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"autoCompact should reject a {tid} runOnce as the wrong type", + ) + for tid, val in [ + ("string", "true"), + ("int32_zero", INT32_ZERO), + ("int32_one", 1), + ("int64", Int64(1)), + ("double_zero", DOUBLE_ZERO), + ("double_one", 1.0), + ("decimal128", Decimal128("1")), + ("object", {"$exists": True}), + ("array", []), + ("objectid", ObjectId()), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01")), + ("regex", Regex(".*")), + ("code", Code("x")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] + ], + CommandTestCase( + "runonce_array_single_bool", + command=lambda ctx: {"autoCompact": True, "runOnce": [True]}, + error_code=TYPE_MISMATCH_ERROR, + msg="autoCompact should reject a single-element bool array runOnce without unwrapping it", + ), +] + +# Property [Unknown Field Handling]: an unknown top-level field is rejected with +# an unknown-field error, and known option names are case-sensitive so wrong-case +# variants are treated as unknown fields and rejected the same way. +AUTOCOMPACT_UNKNOWN_FIELD_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "unknown_field", + command=lambda ctx: {"autoCompact": True, "bogusField": 1}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="autoCompact should reject an unknown top-level field", + ), + CommandTestCase( + "unknown_field_fstmb_capitalized", + command=lambda ctx: {"autoCompact": True, "FreeSpaceTargetMB": 20}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="autoCompact should treat a wrong-case variant of a known option as an unknown field", + ), +] + +# Property [Generic Envelope Field Rejection]: a writeConcern envelope field is +# rejected with an unsupported-options error on both the enable and disable +# paths. +AUTOCOMPACT_WRITE_CONCERN_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "write_concern_enable", + command=lambda ctx: {"autoCompact": True, "writeConcern": {"w": 1}}, + error_code=INVALID_OPTIONS_ERROR, + msg="autoCompact enable should reject writeConcern as an unsupported envelope field", + ), + CommandTestCase( + "write_concern_disable", + command=lambda ctx: {"autoCompact": False, "writeConcern": {"w": 1}}, + error_code=INVALID_OPTIONS_ERROR, + msg="autoCompact disable should reject writeConcern as an unsupported envelope field", + ), +] + +AUTOCOMPACT_REQUEST_VALIDATION_TESTS: list[CommandTestCase] = ( + AUTOCOMPACT_NULL_COMMAND_TESTS + + AUTOCOMPACT_VALUE_TYPE_STRICTNESS_TESTS + + AUTOCOMPACT_FSTMB_TYPE_STRICTNESS_TESTS + + AUTOCOMPACT_RUNONCE_TYPE_STRICTNESS_TESTS + + AUTOCOMPACT_UNKNOWN_FIELD_TESTS + + AUTOCOMPACT_WRITE_CONCERN_TESTS +) + + +@pytest.mark.no_parallel +@pytest.mark.parametrize("test", pytest_params(AUTOCOMPACT_REQUEST_VALIDATION_TESTS)) +def test_autoCompact_request_validation(database_client, collection, test): + """Test autoCompact rejection of malformed requests (type, null, bad fields).""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + # 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)) + 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/autoCompact/test_autoCompact_success.py b/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_autoCompact_success.py new file mode 100644 index 000000000..b24e6c915 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_autoCompact_success.py @@ -0,0 +1,126 @@ +"""Tests for the autoCompact command: successful requests and accepted inputs.""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.compatibility.tests.system.administration.commands.autoCompact.utils.autoCompact_common import ( # noqa: E501 + ensure_autocompact_idle, +) +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 + +# Property [Response Format]: a successful autoCompact returns a bare ok +# response with no command-specific result fields. +AUTOCOMPACT_RESPONSE_FORMAT_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "response_enable", + command=lambda ctx: {"autoCompact": True}, + expected={ + "ok": [Eq(1.0), IsType("double")], + "autoCompact": NotExists(), + }, + msg="autoCompact enable should return ok:1.0 as a double with no command-specific fields", + ), + CommandTestCase( + "response_disable", + command=lambda ctx: {"autoCompact": False}, + expected={ + "ok": [Eq(1.0), IsType("double")], + "autoCompact": NotExists(), + }, + msg="autoCompact disable should return ok:1.0 as a double with no command-specific fields", + ), + CommandTestCase( + "response_enable_with_options", + command=lambda ctx: { + "autoCompact": True, + "freeSpaceTargetMB": 1, + "runOnce": False, + }, + expected={ + "ok": [Eq(1.0), IsType("double")], + "autoCompact": NotExists(), + "freeSpaceTargetMB": NotExists(), + "runOnce": NotExists(), + }, + msg="autoCompact enable with options should not echo freeSpaceTargetMB or runOnce", + ), +] + +# Property [Null Optional Fields]: a null freeSpaceTargetMB or runOnce is +# accepted and treated identically to omitting the field. +AUTOCOMPACT_NULL_OPTIONAL_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "null_fstmb", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": None}, + expected={"ok": Eq(1.0)}, + msg="autoCompact should treat a null freeSpaceTargetMB as omitted", + ), + CommandTestCase( + "null_runonce", + command=lambda ctx: {"autoCompact": True, "runOnce": None}, + expected={"ok": Eq(1.0)}, + msg="autoCompact should treat a null runOnce as omitted", + ), +] + +# Property [Value Behavior]: runOnce:true is accepted alongside a disable +# (autoCompact:false) rather than rejected as contradictory. +AUTOCOMPACT_VALUE_BEHAVIOR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "value_disable_with_runonce", + command=lambda ctx: {"autoCompact": False, "runOnce": True}, + expected={"ok": Eq(1.0)}, + msg="autoCompact should accept runOnce:true alongside a disable and return ok:1.0", + ), +] + +# Property [runOnce Accepted Values]: both runOnce:true and runOnce:false are +# accepted on the enable path. +AUTOCOMPACT_RUNONCE_ACCEPTED_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "runonce_true", + command=lambda ctx: {"autoCompact": True, "runOnce": True}, + expected={"ok": Eq(1.0)}, + msg="autoCompact should accept runOnce:true and return ok:1.0", + ), + CommandTestCase( + "runonce_false", + command=lambda ctx: {"autoCompact": True, "runOnce": False}, + expected={"ok": Eq(1.0)}, + msg="autoCompact should accept runOnce:false and return ok:1.0", + ), +] + +AUTOCOMPACT_SUCCESS_TESTS: list[CommandTestCase] = ( + AUTOCOMPACT_RESPONSE_FORMAT_TESTS + + AUTOCOMPACT_NULL_OPTIONAL_TESTS + + AUTOCOMPACT_VALUE_BEHAVIOR_TESTS + + AUTOCOMPACT_RUNONCE_ACCEPTED_TESTS +) + + +@pytest.mark.no_parallel +@pytest.mark.parametrize("test", pytest_params(AUTOCOMPACT_SUCCESS_TESTS)) +def test_autoCompact_success(database_client, collection, test): + """Test autoCompact successful requests and accepted inputs.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + # 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)) + 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/autoCompact/test_smoke_autoCompact.py b/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_smoke_autoCompact.py index 06d752df2..5bb8b7954 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 @@ -6,14 +6,20 @@ import pytest +from documentdb_tests.compatibility.tests.system.administration.commands.autoCompact.utils.autoCompact_common import ( # noqa: E501 + ensure_autocompact_idle, +) from documentdb_tests.framework.assertions import assertSuccessPartial from documentdb_tests.framework.executor import execute_admin_command -pytestmark = pytest.mark.smoke +pytestmark = [pytest.mark.smoke, pytest.mark.no_parallel] def test_smoke_autoCompact(collection): """Test basic autoCompact behavior.""" + # 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}) expected = {"ok": 1.0} diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/utils/__init__.py b/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/utils/__init__.py new file mode 100644 index 000000000..e69de29bb 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 new file mode 100644 index 000000000..4b325ed7a --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/utils/autoCompact_common.py @@ -0,0 +1,33 @@ +"""Shared helpers for autoCompact command tests.""" + +from __future__ import annotations + +import time + +from documentdb_tests.framework.executor import execute_admin_command + + +def ensure_autocompact_idle(collection): + """Disable autoCompact, retrying until it reaches a deterministic idle state. + + 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. + + 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 + 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") diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/compactStructuredEncryptionData/__init__.py b/documentdb_tests/compatibility/tests/system/administration/commands/compactStructuredEncryptionData/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/compactStructuredEncryptionData/test_compactStructuredEncryptionData_core_behavior.py b/documentdb_tests/compatibility/tests/system/administration/commands/compactStructuredEncryptionData/test_compactStructuredEncryptionData_core_behavior.py new file mode 100644 index 000000000..5e3e38ad7 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/compactStructuredEncryptionData/test_compactStructuredEncryptionData_core_behavior.py @@ -0,0 +1,80 @@ +"""Tests for compactStructuredEncryptionData core behavior. + +Verifies the command correctly rejects non-encrypted collections with error 6346807 +and handles non-existent collections. +""" + +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 ( + NAMESPACE_NOT_FOUND_ERROR, + NOT_ENCRYPTED_COLLECTION_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = pytest.mark.admin + +# Property [Non-Encrypted Rejection]: compactStructuredEncryptionData rejects +# collections that are not configured for Queryable Encryption with error 6346807. +CORE_BEHAVIOR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "empty_compaction_tokens", + docs=[], + command=lambda ctx: { + "compactStructuredEncryptionData": ctx.collection, + "compactionTokens": {}, + }, + error_code=NOT_ENCRYPTED_COLLECTION_ERROR, + msg="compactStructuredEncryptionData should reject non-encrypted collection" + " with empty tokens", + ), + CommandTestCase( + "non_empty_compaction_tokens", + docs=[], + command=lambda ctx: { + "compactStructuredEncryptionData": ctx.collection, + "compactionTokens": {"field": b"\x00\x01\x02"}, + }, + error_code=NOT_ENCRYPTED_COLLECTION_ERROR, + msg="compactStructuredEncryptionData should reject non-encrypted collection with tokens", + ), + CommandTestCase( + "collection_with_documents", + docs=[{"_id": 1, "name": "test"}, {"_id": 2, "name": "data"}], + command=lambda ctx: { + "compactStructuredEncryptionData": ctx.collection, + "compactionTokens": {}, + }, + error_code=NOT_ENCRYPTED_COLLECTION_ERROR, + msg="compactStructuredEncryptionData should reject non-encrypted collection with documents", + ), + CommandTestCase( + "nonexistent_collection", + command=lambda ctx: { + "compactStructuredEncryptionData": "nonexistent_collection_xyz", + "compactionTokens": {}, + }, + error_code=NAMESPACE_NOT_FOUND_ERROR, + msg="compactStructuredEncryptionData should error on non-existent collection", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(CORE_BEHAVIOR_TESTS)) +def test_compactStructuredEncryptionData_core_behavior(database_client, collection, test): + """Test compactStructuredEncryptionData core behavior on non-encrypted collections.""" + collection = test.prepare(database_client, collection) + 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/system/administration/commands/compactStructuredEncryptionData/test_compactStructuredEncryptionData_edge_cases.py b/documentdb_tests/compatibility/tests/system/administration/commands/compactStructuredEncryptionData/test_compactStructuredEncryptionData_edge_cases.py new file mode 100644 index 000000000..4667ec137 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/compactStructuredEncryptionData/test_compactStructuredEncryptionData_edge_cases.py @@ -0,0 +1,70 @@ +"""Tests for compactStructuredEncryptionData edge cases. + +Covers collection name edge cases. +""" + +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 ( + NAMESPACE_NOT_FOUND_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = pytest.mark.admin + +# Property [Collection Name Edge Cases]: compactStructuredEncryptionData handles +# special collection name patterns correctly. +COLLECTION_NAME_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "system_prefix", + command=lambda ctx: { + "compactStructuredEncryptionData": "system.buckets.test", + "compactionTokens": {}, + }, + error_code=NAMESPACE_NOT_FOUND_ERROR, + msg="compactStructuredEncryptionData should reject system.* prefix" + " collection names with namespace-not-found", + ), + CommandTestCase( + "dotted_name", + command=lambda ctx: { + "compactStructuredEncryptionData": "a.b.c", + "compactionTokens": {}, + }, + error_code=NAMESPACE_NOT_FOUND_ERROR, + msg="compactStructuredEncryptionData should reject multi-segment" + " dotted names with namespace-not-found", + ), + CommandTestCase( + "dollar_prefix", + command=lambda ctx: { + "compactStructuredEncryptionData": "$myCollection", + "compactionTokens": {}, + }, + error_code=NAMESPACE_NOT_FOUND_ERROR, + msg="compactStructuredEncryptionData should reject dollar-prefixed" + " collection names with namespace-not-found", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(COLLECTION_NAME_TESTS)) +def test_compactStructuredEncryptionData_collection_name_edge_cases( + database_client, collection, test +): + """Test compactStructuredEncryptionData rejects special collection name patterns.""" + collection = test.prepare(database_client, collection) + 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/system/administration/commands/compactStructuredEncryptionData/test_compactStructuredEncryptionData_error_cases.py b/documentdb_tests/compatibility/tests/system/administration/commands/compactStructuredEncryptionData/test_compactStructuredEncryptionData_error_cases.py new file mode 100644 index 000000000..d7b1e07ab --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/compactStructuredEncryptionData/test_compactStructuredEncryptionData_error_cases.py @@ -0,0 +1,92 @@ +"""Tests for compactStructuredEncryptionData error cases. + +Covers unrecognized fields and collection type variants (views, capped). +""" + +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 ( + COMMAND_NOT_SUPPORTED_ON_VIEW_ERROR, + NOT_ENCRYPTED_COLLECTION_ERROR, + UNRECOGNIZED_COMMAND_FIELD_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.target_collection import CappedCollection, ViewCollection + +pytestmark = pytest.mark.admin + +# Property [Unrecognized Field Rejection]: compactStructuredEncryptionData rejects +# commands with unrecognized fields. +UNRECOGNIZED_FIELD_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "extra_field", + docs=[], + command=lambda ctx: { + "compactStructuredEncryptionData": ctx.collection, + "compactionTokens": {}, + "unknownField": 1, + }, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="compactStructuredEncryptionData should reject unrecognized fields", + ), + CommandTestCase( + "similar_field_name", + docs=[], + command=lambda ctx: { + "compactStructuredEncryptionData": ctx.collection, + "compactionTokens": {}, + "compactionToken": {}, + }, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="compactStructuredEncryptionData should reject fields with similar names", + ), +] + +# Property [Collection Type Rejection]: compactStructuredEncryptionData rejects +# views and returns non-encrypted error for capped collections. +COLLECTION_VARIANT_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "on_view", + docs=[{"_id": 1}], + target_collection=ViewCollection(), + command=lambda ctx: { + "compactStructuredEncryptionData": ctx.collection, + "compactionTokens": {}, + }, + error_code=COMMAND_NOT_SUPPORTED_ON_VIEW_ERROR, + msg="compactStructuredEncryptionData should reject views", + ), + CommandTestCase( + "on_capped_collection", + docs=[{"_id": 1}], + target_collection=CappedCollection(), + command=lambda ctx: { + "compactStructuredEncryptionData": ctx.collection, + "compactionTokens": {}, + }, + error_code=NOT_ENCRYPTED_COLLECTION_ERROR, + msg="compactStructuredEncryptionData should reject non-encrypted capped collection", + ), +] + +ERROR_TESTS = UNRECOGNIZED_FIELD_TESTS + COLLECTION_VARIANT_TESTS + + +@pytest.mark.parametrize("test", pytest_params(ERROR_TESTS)) +def test_compactStructuredEncryptionData_errors(database_client, collection, test): + """Test compactStructuredEncryptionData error conditions.""" + collection = test.prepare(database_client, collection) + 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/system/administration/commands/compactStructuredEncryptionData/test_compactStructuredEncryptionData_field_validation.py b/documentdb_tests/compatibility/tests/system/administration/commands/compactStructuredEncryptionData/test_compactStructuredEncryptionData_field_validation.py new file mode 100644 index 000000000..b21e85008 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/compactStructuredEncryptionData/test_compactStructuredEncryptionData_field_validation.py @@ -0,0 +1,109 @@ +"""Tests for compactStructuredEncryptionData command field validation. + +Covers collection name type validation (§19 representative case), +compactionTokens BSON type rejection, and missing field errors. +""" + +import pytest + +from documentdb_tests.framework.assertions import assertFailureCode +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 ( + INVALID_NAMESPACE_ERROR, + MISSING_FIELD_ERROR, + NOT_ENCRYPTED_COLLECTION_ERROR, + TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.test_constants import BsonType + +pytestmark = pytest.mark.admin + +# Property [CompactionTokens Type Rejection]: compactStructuredEncryptionData rejects +# non-document types for the compactionTokens field. +BSON_TYPE_PARAMS = [ + BsonTypeTestCase( + id="compactionTokens_type", + msg="compactionTokens should reject non-document types", + keyword="compactionTokens", + valid_types=[BsonType.OBJECT], + default_error_code=TYPE_MISMATCH_ERROR, + error_code_overrides={BsonType.NULL: MISSING_FIELD_ERROR}, + ), +] + +REJECTION_CASES = generate_bson_rejection_test_cases(BSON_TYPE_PARAMS) +ACCEPTANCE_CASES = generate_bson_acceptance_test_cases(BSON_TYPE_PARAMS) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", REJECTION_CASES) +def test_compactStructuredEncryptionData_rejects_invalid_compactionTokens_type( + collection, bson_type, sample_value, spec +): + """Test compactStructuredEncryptionData rejects invalid BSON types for compactionTokens.""" + cmd = { + "compactStructuredEncryptionData": collection.name, + "compactionTokens": sample_value, + } + result = execute_command(collection, cmd) + assertFailureCode(result, spec.expected_code(bson_type), msg=spec.msg) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", ACCEPTANCE_CASES) +def test_compactStructuredEncryptionData_accepts_valid_compactionTokens_type( + collection, bson_type, sample_value, spec +): + """Test compactStructuredEncryptionData accepts document type for compactionTokens. + + The command accepts the type but fails because the collection is not encrypted. + Error 6346807 confirms the type was accepted and processing continued. + """ + collection.insert_one({"_id": 1}) + cmd = { + "compactStructuredEncryptionData": collection.name, + "compactionTokens": sample_value, + } + result = execute_command(collection, cmd) + assertFailureCode( + result, + NOT_ENCRYPTED_COLLECTION_ERROR, + msg=spec.msg, + ) + + +def test_compactStructuredEncryptionData_rejects_non_string_collection_name(collection): + """Test compactStructuredEncryptionData rejects non-string collection name.""" + cmd = {"compactStructuredEncryptionData": 1, "compactionTokens": {}} + result = execute_command(collection, cmd) + assertFailureCode( + result, + INVALID_NAMESPACE_ERROR, + msg="compactStructuredEncryptionData should reject non-string collection name", + ) + + +def test_compactStructuredEncryptionData_rejects_empty_collection_name(collection): + """Test compactStructuredEncryptionData rejects empty string collection name.""" + cmd = {"compactStructuredEncryptionData": "", "compactionTokens": {}} + result = execute_command(collection, cmd) + assertFailureCode( + result, + INVALID_NAMESPACE_ERROR, + msg="compactStructuredEncryptionData should reject empty collection name", + ) + + +def test_compactStructuredEncryptionData_missing_compactionTokens(collection): + """Test compactStructuredEncryptionData requires compactionTokens field.""" + collection.insert_one({"_id": 1}) + cmd = {"compactStructuredEncryptionData": collection.name} + result = execute_command(collection, cmd) + assertFailureCode( + result, + MISSING_FIELD_ERROR, + msg="compactStructuredEncryptionData should error when compactionTokens is missing", + ) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/compactStructuredEncryptionData/test_compactStructuredEncryptionData_qe_collection.py b/documentdb_tests/compatibility/tests/system/administration/commands/compactStructuredEncryptionData/test_compactStructuredEncryptionData_qe_collection.py new file mode 100644 index 000000000..af5c5483f --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/compactStructuredEncryptionData/test_compactStructuredEncryptionData_qe_collection.py @@ -0,0 +1,134 @@ +"""Tests for compactStructuredEncryptionData on Queryable Encryption collections. + +Verifies the success path, missing-token rejection, and token content validation +on collections that are actually configured for Queryable Encryption. These tests +require a replica set (QE collection creation fails on standalone with 6346402). +""" + +from __future__ import annotations + +from uuid import uuid4 + +import pytest +from bson import Binary + +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 MISSING_COMPACT_TOKEN_ERROR +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = pytest.mark.requires(queryable_encryption=True) + + +@pytest.fixture() +def qe_collection(collection): + """Create a Queryable Encryption collection with one encrypted field.""" + db = collection.database + qe_name = f"{collection.name}_qe" + db.command( + "create", + qe_name, + encryptedFields={ + "fields": [ + { + "path": "ssn", + "bsonType": "string", + "keyId": Binary(uuid4().bytes, 4), + } + ] + }, + ) + yield db[qe_name] + db.drop_collection(qe_name) + + +# Property [Success Path]: compactStructuredEncryptionData succeeds on a QE collection +# with a valid compaction token and returns stats. +SUCCESS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "valid_token", + command=lambda ctx: { + "compactStructuredEncryptionData": ctx.collection, + "compactionTokens": {"ssn": Binary(b"\x00" * 32, 0)}, + }, + expected={"ok": 1.0}, + msg="compactStructuredEncryptionData should succeed with valid token on QE collection.", + ), + CommandTestCase( + "null_token_value", + command=lambda ctx: { + "compactStructuredEncryptionData": ctx.collection, + "compactionTokens": {"ssn": None}, + }, + expected={"ok": 1.0}, + msg="compactStructuredEncryptionData should accept null token value on QE collection.", + ), + CommandTestCase( + "nested_document_token_value", + command=lambda ctx: { + "compactStructuredEncryptionData": ctx.collection, + "compactionTokens": {"ssn": {"nested": b"\x00\x01"}}, + }, + expected={"ok": 1.0}, + msg="compactStructuredEncryptionData should accept nested document token value" + " on QE collection.", + ), +] + +# Property [Token Rejection]: compactStructuredEncryptionData rejects tokens that do not +# match an encrypted path on a QE collection. +ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "missing_token_empty_tokens", + command=lambda ctx: { + "compactStructuredEncryptionData": ctx.collection, + "compactionTokens": {}, + }, + error_code=MISSING_COMPACT_TOKEN_ERROR, + msg="compactStructuredEncryptionData should reject empty compactionTokens" + " on QE collection.", + ), + CommandTestCase( + "empty_string_key", + command=lambda ctx: { + "compactStructuredEncryptionData": ctx.collection, + "compactionTokens": {"": Binary(b"\x00" * 32, 0)}, + }, + error_code=MISSING_COMPACT_TOKEN_ERROR, + msg="compactStructuredEncryptionData should reject empty-string token key" + " that does not match an encrypted path.", + ), + CommandTestCase( + "dot_notation_key", + command=lambda ctx: { + "compactStructuredEncryptionData": ctx.collection, + "compactionTokens": {"a.b": Binary(b"\x00" * 32, 0)}, + }, + error_code=MISSING_COMPACT_TOKEN_ERROR, + msg="compactStructuredEncryptionData should reject dot-notation token key" + " that does not match an encrypted path.", + ), +] + +QE_SUCCESS_TESTS: list[CommandTestCase] = SUCCESS_TESTS +QE_ERROR_TESTS: list[CommandTestCase] = ERROR_TESTS + + +@pytest.mark.parametrize("test", pytest_params(QE_SUCCESS_TESTS)) +def test_compactStructuredEncryptionData_qe_success(qe_collection, test): + """Test compactStructuredEncryptionData succeeds on QE collection.""" + ctx = CommandContext.from_collection(qe_collection) + result = execute_command(qe_collection, test.build_command(ctx)) + assertSuccessPartial(result, test.expected, msg=test.msg) + + +@pytest.mark.parametrize("test", pytest_params(QE_ERROR_TESTS)) +def test_compactStructuredEncryptionData_qe_error(qe_collection, test): + """Test compactStructuredEncryptionData rejects invalid tokens on QE collection.""" + ctx = CommandContext.from_collection(qe_collection) + result = execute_command(qe_collection, test.build_command(ctx)) + assertFailureCode(result, test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/compactStructuredEncryptionData/test_smoke_compactStructuredEncryptionData.py b/documentdb_tests/compatibility/tests/system/administration/commands/compactStructuredEncryptionData/test_smoke_compactStructuredEncryptionData.py index 7f8c7cb93..077f2ffec 100644 --- a/documentdb_tests/compatibility/tests/system/administration/commands/compactStructuredEncryptionData/test_smoke_compactStructuredEncryptionData.py +++ b/documentdb_tests/compatibility/tests/system/administration/commands/compactStructuredEncryptionData/test_smoke_compactStructuredEncryptionData.py @@ -1,12 +1,12 @@ -""" -Smoke test for compactStructuredEncryptionData command. +"""Smoke test for compactStructuredEncryptionData command. Tests basic compactStructuredEncryptionData functionality. """ import pytest -from documentdb_tests.framework.assertions import assertFailure +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import NOT_ENCRYPTED_COLLECTION_ERROR from documentdb_tests.framework.executor import execute_command pytestmark = pytest.mark.smoke @@ -20,5 +20,8 @@ def test_smoke_compactStructuredEncryptionData(collection): collection, {"compactStructuredEncryptionData": collection.name, "compactionTokens": {}} ) - expected = {"code": 6346807, "msg": "Target namespace is not an encrypted collection"} - assertFailure(result, expected, msg="Should support compactStructuredEncryptionData command") + assertFailureCode( + result, + NOT_ENCRYPTED_COLLECTION_ERROR, + msg="compactStructuredEncryptionData should reject non-encrypted collection", + ) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/fsyncUnlock/conftest.py b/documentdb_tests/compatibility/tests/system/administration/commands/fsyncUnlock/conftest.py new file mode 100644 index 000000000..2cc68cf91 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/fsyncUnlock/conftest.py @@ -0,0 +1,11 @@ +"""Shared fixtures for fsyncUnlock 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/fsyncUnlock/test_fsyncUnlock.py b/documentdb_tests/compatibility/tests/system/administration/commands/fsyncUnlock/test_fsyncUnlock.py new file mode 100644 index 000000000..14b18c0e9 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/fsyncUnlock/test_fsyncUnlock.py @@ -0,0 +1,366 @@ +"""Tests for fsyncUnlock command behavior.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import pytest +from bson import Int64 +from pymongo import MongoClient + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandTestCase, +) +from documentdb_tests.framework.assertions import ( + assertFailureCode, + assertResult, + assertSuccessPartial, +) +from documentdb_tests.framework.error_codes import ( + API_STRICT_ERROR, + ILLEGAL_OPERATION_ERROR, + INVALID_OPTIONS_ERROR, + OPERATION_NOT_SUPPORTED_IN_TRANSACTION_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.property_checks import Eq, IsType, NotExists +from documentdb_tests.framework.test_constants import ( + BSON_TYPE_SAMPLES, + DOUBLE_ZERO, + INT64_ZERO, +) + +# fsyncUnlock decrements a server-global lock count, so these tests must never +# run in parallel with anything that takes or releases the fsync lock. +pytestmark = pytest.mark.no_parallel + + +# Sentinel marking an FsyncUnlockCase field that the caller must set. Every +# field inherited from CommandTestCase has a default, so the dataclass cannot +# make these positionally required; instead they default to this sentinel and +# __post_init__ rejects it, forcing each case to state its lock-state +# preconditions explicitly rather than inheriting a hidden default. +_REQUIRED = object() + + +@dataclass(frozen=True) +class FsyncUnlockCase(CommandTestCase): + """A command-test case carrying fsyncUnlock's lock-state preconditions. + + fsyncUnlock's success path depends on the current lock count, which is not + derivable from the command itself, so each case declares how many fsync + locks to take and how many unlocks to issue before the command under test + runs. Both are required: no default lock state is assumed. + """ + + locks_taken: int = _REQUIRED # type: ignore[assignment] + unlocks_before: int = _REQUIRED # type: ignore[assignment] + + def __post_init__(self) -> None: + super().__post_init__() + if self.locks_taken is _REQUIRED or self.unlocks_before is _REQUIRED: + raise ValueError( + f"FsyncUnlockCase '{self.id}' must set locks_taken and unlocks_before explicitly" + ) + + +# Property [Response Shape and Return Types]: lockCount is a BSON Int64 holding +# the remaining lock count (not a hardcoded 0) and seeAlso is absent (it belongs +# only to the fsync lock response). +FSYNCUNLOCK_RESPONSE_SHAPE_TESTS: list[FsyncUnlockCase] = [ + FsyncUnlockCase( + "response_shape", + # Lock twice so the unlock leaves a nonzero remaining count; this proves + # lockCount reflects the locks remaining rather than a hardcoded 0. + locks_taken=2, + unlocks_before=0, + command={"fsyncUnlock": 1}, + expected={ + "info": Eq("fsyncUnlock completed"), + "lockCount": [IsType("long"), Eq(Int64(1))], + "ok": [IsType("double"), Eq(1.0)], + "seeAlso": NotExists(), + }, + msg="fsyncUnlock should return info, an Int64 lockCount of the remaining " + "locks, a double ok of 1.0, and no seeAlso field", + ), +] + +# Property [Command-Key Value Handling]: the command-key value is ignored across +# every BSON type, so each still succeeds and decrements normally. +FSYNCUNLOCK_COMMAND_KEY_TESTS: list[FsyncUnlockCase] = [ + FsyncUnlockCase( + f"command_key_{bson_type.value}", + locks_taken=1, + unlocks_before=0, + command={"fsyncUnlock": val}, + expected={"lockCount": Eq(INT64_ZERO), "ok": Eq(1.0)}, + msg="fsyncUnlock should ignore its command-key value and decrement the " + "lock count by exactly 1 on the locked path", + ) + for bson_type, val in BSON_TYPE_SAMPLES.items() +] + +# Property [Comment Field Handling]: a comment of any BSON type is accepted +# untyped, succeeds, and is never echoed in the reply. +FSYNCUNLOCK_COMMENT_TESTS: list[FsyncUnlockCase] = [ + FsyncUnlockCase( + f"comment_{bson_type.value}", + locks_taken=1, + unlocks_before=0, + command={"fsyncUnlock": 1, "comment": val}, + expected={"lockCount": Eq(INT64_ZERO), "ok": Eq(1.0), "comment": NotExists()}, + msg="fsyncUnlock should accept any comment value, decrement the lock " + "count by exactly 1, and never echo the comment in the reply", + ) + for bson_type, val in BSON_TYPE_SAMPLES.items() +] + +# Property [Generic Command Options Accepted]: generic command-envelope options +# fall through to the normal path, leaving the success+decrement outcome +# unchanged. +FSYNCUNLOCK_GENERIC_OPTION_TESTS: list[FsyncUnlockCase] = [ + FsyncUnlockCase( + "generic_read_concern_local", + locks_taken=1, + unlocks_before=0, + command={"fsyncUnlock": 1, "readConcern": {"level": "local"}}, + expected={"lockCount": Eq(INT64_ZERO), "ok": Eq(1.0)}, + msg="fsyncUnlock should accept the generic command option and decrement " + "the lock count by exactly 1 on the locked path", + ), + FsyncUnlockCase( + "generic_read_preference_primary", + locks_taken=1, + unlocks_before=0, + command={"fsyncUnlock": 1, "$readPreference": {"mode": "primary"}}, + expected={"lockCount": Eq(INT64_ZERO), "ok": Eq(1.0)}, + msg="fsyncUnlock should accept the generic command option and decrement " + "the lock count by exactly 1 on the locked path", + ), + FsyncUnlockCase( + "generic_read_preference_secondary_preferred", + locks_taken=1, + unlocks_before=0, + command={"fsyncUnlock": 1, "$readPreference": {"mode": "secondaryPreferred"}}, + expected={"lockCount": Eq(INT64_ZERO), "ok": Eq(1.0)}, + msg="fsyncUnlock should accept the generic command option and decrement " + "the lock count by exactly 1 on the locked path", + ), + FsyncUnlockCase( + "generic_max_time_ms_zero", + locks_taken=1, + unlocks_before=0, + command={"fsyncUnlock": 1, "maxTimeMS": 0}, + expected={"lockCount": Eq(INT64_ZERO), "ok": Eq(1.0)}, + msg="fsyncUnlock should accept the generic command option and decrement " + "the lock count by exactly 1 on the locked path", + ), + FsyncUnlockCase( + "generic_max_time_ms_zero_float", + locks_taken=1, + unlocks_before=0, + command={"fsyncUnlock": 1, "maxTimeMS": DOUBLE_ZERO}, + expected={"lockCount": Eq(INT64_ZERO), "ok": Eq(1.0)}, + msg="fsyncUnlock should accept the generic command option and decrement " + "the lock count by exactly 1 on the locked path", + ), + FsyncUnlockCase( + "generic_unknown_extra_field", + locks_taken=1, + unlocks_before=0, + command={"fsyncUnlock": 1, "someUnknownField": 1}, + expected={"lockCount": Eq(INT64_ZERO), "ok": Eq(1.0)}, + msg="fsyncUnlock should accept the generic command option and decrement " + "the lock count by exactly 1 on the locked path", + ), +] + +# Property [Error: Instance Not Locked]: unlocking at lock count 0 errors with +# IllegalOperation rather than no-opping, whether never raised or driven back to +# 0 by over-unlocking. +FSYNCUNLOCK_NOT_LOCKED_TESTS: list[FsyncUnlockCase] = [ + FsyncUnlockCase( + "not_locked_never_raised", + locks_taken=0, + unlocks_before=0, + command={"fsyncUnlock": 1}, + error_code=ILLEGAL_OPERATION_ERROR, + msg="fsyncUnlock should error as not-locked when the lock count is " + "already 0 instead of silently no-opping", + ), + FsyncUnlockCase( + "over_unlock_below_zero", + locks_taken=1, + unlocks_before=1, + command={"fsyncUnlock": 1}, + error_code=ILLEGAL_OPERATION_ERROR, + msg="fsyncUnlock should error as not-locked when the lock count is " + "already 0 instead of silently no-opping", + ), +] + +# Property [Error: writeConcern Not Supported]: a writeConcern envelope errors +# with InvalidOptions even with a lock held; the command does not support +# writeConcern. +FSYNCUNLOCK_WRITE_CONCERN_TESTS: list[FsyncUnlockCase] = [ + FsyncUnlockCase( + "write_concern_w1", + locks_taken=1, + unlocks_before=0, + command={"fsyncUnlock": 1, "writeConcern": {"w": 1}}, + error_code=INVALID_OPTIONS_ERROR, + msg="fsyncUnlock should reject a writeConcern envelope while a lock is held", + ), +] + +# Property [Error: readConcern Non-Local Levels]: a non-local readConcern level +# errors with InvalidOptions even with a lock held; only the local level is +# supported. +FSYNCUNLOCK_READ_CONCERN_TESTS: list[FsyncUnlockCase] = [ + FsyncUnlockCase( + f"read_concern_{level}", + locks_taken=1, + unlocks_before=0, + command={"fsyncUnlock": 1, "readConcern": {"level": level}}, + error_code=INVALID_OPTIONS_ERROR, + msg="fsyncUnlock should reject a non-local readConcern level while a lock is held", + ) + for level in ("majority", "linearizable", "available", "snapshot") +] + +# Property [Error: Stable API Rejection]: under apiVersion 1 + apiStrict the +# command errors with APIStrictError even with a lock held; it is not in API +# Version 1. +FSYNCUNLOCK_API_STRICT_TESTS: list[FsyncUnlockCase] = [ + FsyncUnlockCase( + "api_strict", + locks_taken=1, + unlocks_before=0, + command={"fsyncUnlock": 1, "apiVersion": "1", "apiStrict": True}, + error_code=API_STRICT_ERROR, + msg="fsyncUnlock should be rejected under apiStrict true while a lock is held", + ), +] + +FSYNCUNLOCK_TESTS = ( + FSYNCUNLOCK_RESPONSE_SHAPE_TESTS + + FSYNCUNLOCK_COMMAND_KEY_TESTS + + FSYNCUNLOCK_COMMENT_TESTS + + FSYNCUNLOCK_GENERIC_OPTION_TESTS + + FSYNCUNLOCK_NOT_LOCKED_TESTS + + FSYNCUNLOCK_WRITE_CONCERN_TESTS + + FSYNCUNLOCK_READ_CONCERN_TESTS + + FSYNCUNLOCK_API_STRICT_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(FSYNCUNLOCK_TESTS)) +def test_fsyncUnlock_cases(collection, test): + """Test fsyncUnlock cases against its response contract on the admin database.""" + for _ in range(test.locks_taken): + execute_admin_command(collection, {"fsync": 1, "lock": True}) + for _ in range(test.unlocks_before): + execute_admin_command(collection, {"fsyncUnlock": 1}) + result = execute_admin_command(collection, test.command) + assertResult( + result, + expected=test.expected, + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + + +# Property [Error: Non-Admin Database]: a non-admin database errors with +# Unauthorized even with a lock held - the admin-scope dispatch check, not an +# auth-privilege failure. +def test_fsyncUnlock_rejects_non_admin_database(collection): + """Test fsyncUnlock rejects a non-admin database while a lock is held.""" + execute_admin_command(collection, {"fsync": 1, "lock": True}) + result = execute_command(collection, {"fsyncUnlock": 1}) + assertFailureCode( + result, + UNAUTHORIZED_ERROR, + msg="fsyncUnlock should reject a non-admin database while a lock is held", + ) + + +# Property [Cross-Connection Lock Sharing]: the lock count is server-global, so +# an unlock on one connection releases a lock taken on another. +def test_fsyncUnlock_releases_lock_taken_on_another_connection(collection, connection_string): + """Test fsyncUnlock releases a lock that was taken on a different connection.""" + other_client: MongoClient = MongoClient(connection_string) + try: + # Take the lock on a separate connection and pool. + other_client.admin.command({"fsync": 1, "lock": True}) + # Release it from the primary connection; if the count were + # per-connection this unlock would instead error as not-locked. + result = execute_admin_command(collection, {"fsyncUnlock": 1}) + assertSuccessPartial( + result, + {"lockCount": INT64_ZERO, "ok": 1.0}, + msg="fsyncUnlock should release a lock taken on another connection " + "and report the shared count at 0", + ) + finally: + other_client.close() + + +# Property [Explicit Session Accepted]: an explicit client session is accepted +# and behaves identically to a sessionless invocation. +def test_fsyncUnlock_accepts_explicit_session(collection): + """Test fsyncUnlock runs under an explicit client session and behaves identically.""" + execute_admin_command(collection, {"fsync": 1, "lock": True}) + session = collection.database.client.start_session() + try: + result = execute_admin_command(collection, {"fsyncUnlock": 1}, session=session) + assertSuccessPartial( + result, + {"lockCount": INT64_ZERO, "ok": 1.0}, + msg="fsyncUnlock should run under an explicit session and decrement the " + "lock count by exactly 1", + ) + finally: + session.end_session() + + +# Property [Error: Non-Admin Database Consumes No Lock]: a non-admin rejection +# does not consume a held lock, so a following admin fsyncUnlock still decrements +# the count to 0. +def test_fsyncUnlock_non_admin_consumes_no_lock(collection): + """Test fsyncUnlock non-admin rejection does not consume a held lock.""" + execute_admin_command(collection, {"fsync": 1, "lock": True}) + execute_command(collection, {"fsyncUnlock": 1}) + result = execute_admin_command(collection, {"fsyncUnlock": 1}) + assertSuccessPartial( + result, + {"lockCount": INT64_ZERO, "ok": 1.0}, + msg="fsyncUnlock non-admin rejection should not consume a held lock, so a " + "following real unlock still decrements the count to 0", + ) + + +# Property [Error: Multi-Document Transaction]: inside a multi-document +# transaction fsyncUnlock errors with OperationNotSupportedInTransaction. +@pytest.mark.requires(transactions=True) +def test_fsyncUnlock_rejects_multi_document_transaction(collection): + """Test fsyncUnlock 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, {"fsyncUnlock": 1}, session=session) + finally: + session.abort_transaction() + assertFailureCode( + result, + OPERATION_NOT_SUPPORTED_IN_TRANSACTION_ERROR, + msg="fsyncUnlock should error as not supported when issued inside a " + "multi-document transaction", + ) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/getDefaultRWConcern/test_getDefaultRWConcern_command_options.py b/documentdb_tests/compatibility/tests/system/administration/commands/getDefaultRWConcern/test_getDefaultRWConcern_command_options.py new file mode 100644 index 000000000..85c8a5e11 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/getDefaultRWConcern/test_getDefaultRWConcern_command_options.py @@ -0,0 +1,229 @@ +"""Tests for getDefaultRWConcern command input acceptance and rejection behavior.""" + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) +from bson.binary import UUID_SUBTYPE + +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, + 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, +) + +# Property [Null Field Handling]: a null optional field is accepted and treated +# as absent, so it is not echoed back. +GETDEFAULTRWCONCERN_NULL_FIELD_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "null_in_memory", + command={"getDefaultRWConcern": 1, "inMemory": None}, + expected={"ok": Eq(1.0), "inMemory": NotExists()}, + msg="getDefaultRWConcern should treat a null inMemory as field-absent and not echo it", + ), + CommandTestCase( + "null_comment", + command={"getDefaultRWConcern": 1, "comment": None}, + expected={"ok": Eq(1.0), "comment": NotExists()}, + msg="getDefaultRWConcern should accept a null comment and not echo it", + ), + CommandTestCase( + "null_all", + command={"getDefaultRWConcern": None, "inMemory": None, "comment": None}, + expected={"ok": Eq(1.0), "inMemory": NotExists(), "comment": NotExists()}, + msg="getDefaultRWConcern should accept null command value, inMemory, and comment together", + ), +] + +# Property [Command Value Behavior]: the command value is ignored and never +# type-validated, so any BSON value is accepted. +GETDEFAULTRWCONCERN_COMMAND_VALUE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"command_value_{tid}", + command={"getDefaultRWConcern": val}, + expected={"ok": Eq(1.0)}, + msg=f"getDefaultRWConcern should ignore a {tid} command value and succeed", + ) + for tid, val in [ + ("null", None), + ("int32", 42), + ("int64", Int64(2)), + ("double", 3.14), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool", True), + ("string", "hello"), + ("empty_string", ""), + ("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()), + ("array_single", [1]), + ("object", {"a": 1}), + ] +] + +# Property [inMemory Behavior]: inMemory is echoed back only when set true; +# false or omitted is accepted but not echoed. +GETDEFAULTRWCONCERN_IN_MEMORY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "in_memory_true", + command={"getDefaultRWConcern": 1, "inMemory": True}, + expected={"ok": Eq(1.0), "inMemory": Eq(True)}, + msg="getDefaultRWConcern should accept inMemory true and echo it back as true", + ), + CommandTestCase( + "in_memory_false", + command={"getDefaultRWConcern": 1, "inMemory": False}, + expected={"ok": Eq(1.0), "inMemory": NotExists()}, + msg="getDefaultRWConcern should accept inMemory false and not echo it", + ), + CommandTestCase( + "in_memory_omitted", + command={"getDefaultRWConcern": 1}, + expected={"ok": Eq(1.0), "inMemory": NotExists()}, + msg="getDefaultRWConcern should default omitted inMemory to false and not echo it", + ), +] + +# Property [comment Behavior]: comment accepts any BSON value and is never +# echoed back. +GETDEFAULTRWCONCERN_COMMENT_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"comment_{tid}", + command={"getDefaultRWConcern": 1, "comment": val}, + expected={"ok": Eq(1.0), "comment": NotExists()}, + msg=f"getDefaultRWConcern should accept a {tid} comment and not echo it", + ) + for tid, val in [ + ("string", "hello"), + ("int32", 42), + ("int64", Int64(2)), + ("double", 3.14), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("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()), + ("array", [1, 2, 3]), + ("object", {"a": 1}), + ] +] + +# Property [Generic Command Options (smoke)]: the command accepts the generic +# command options shared across commands. +GETDEFAULTRWCONCERN_GENERIC_OPTIONS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "generic_max_time_ms", + command={"getDefaultRWConcern": 1, "maxTimeMS": 100}, + expected={"ok": Eq(1.0)}, + msg="getDefaultRWConcern should accept a maxTimeMS option", + ), + CommandTestCase( + "generic_api_version", + command={"getDefaultRWConcern": 1, "apiVersion": "1"}, + expected={"ok": Eq(1.0)}, + msg="getDefaultRWConcern should accept an apiVersion option", + ), + CommandTestCase( + "generic_read_preference", + command={"getDefaultRWConcern": 1, "$readPreference": {"mode": "secondary"}}, + expected={"ok": Eq(1.0)}, + msg="getDefaultRWConcern should accept a $readPreference option", + ), + CommandTestCase( + "generic_lsid", + command={"getDefaultRWConcern": 1, "lsid": {"id": Binary(b"\x01" * 16, UUID_SUBTYPE)}}, + expected={"ok": Eq(1.0)}, + msg="getDefaultRWConcern should accept an lsid session option", + ), +] + +# Property [inMemory Type Errors]: inMemory is strictly typed as bool, so every +# non-bool, non-null value is rejected. +GETDEFAULTRWCONCERN_IN_MEMORY_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"in_memory_type_{tid}", + command={"getDefaultRWConcern": 1, "inMemory": val}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"getDefaultRWConcern should reject a {tid} inMemory value as a non-bool", + ) + for tid, val in [ + ("int32", 42), + ("int64", Int64(2)), + ("double", 3.14), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("string", "x"), + ("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()), + ("array", [1]), + ("object", {"a": 1}), + ] +] + +# Property [Syntax Validation Errors]: an unknown top-level command field is +# rejected. +GETDEFAULTRWCONCERN_SYNTAX_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "unknown_field", + command={"getDefaultRWConcern": 1, "unknownField": 1}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="getDefaultRWConcern should reject an unknown command field", + ), +] + +GETDEFAULTRWCONCERN_COMMAND_OPTION_TESTS: list[CommandTestCase] = ( + GETDEFAULTRWCONCERN_NULL_FIELD_TESTS + + GETDEFAULTRWCONCERN_COMMAND_VALUE_TESTS + + GETDEFAULTRWCONCERN_IN_MEMORY_TESTS + + GETDEFAULTRWCONCERN_COMMENT_TESTS + + GETDEFAULTRWCONCERN_GENERIC_OPTIONS_TESTS + + GETDEFAULTRWCONCERN_IN_MEMORY_TYPE_ERROR_TESTS + + GETDEFAULTRWCONCERN_SYNTAX_ERROR_TESTS +) + + +@pytest.mark.requires(cluster_admin=True) +@pytest.mark.parametrize("test", pytest_params(GETDEFAULTRWCONCERN_COMMAND_OPTION_TESTS)) +def test_getDefaultRWConcern_command_options(collection, test): + """Test getDefaultRWConcern command value and option acceptance and rejection behavior.""" + 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/getDefaultRWConcern/test_getDefaultRWConcern_read_concern.py b/documentdb_tests/compatibility/tests/system/administration/commands/getDefaultRWConcern/test_getDefaultRWConcern_read_concern.py new file mode 100644 index 000000000..5833eb3c0 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/getDefaultRWConcern/test_getDefaultRWConcern_read_concern.py @@ -0,0 +1,264 @@ +"""Tests for getDefaultRWConcern command input acceptance and rejection behavior.""" + +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, + 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 +from documentdb_tests.framework.test_constants import ( + DECIMAL128_ONE_AND_HALF, +) + +# Property [readConcern Acceptance]: the command accepts a readConcern that +# resolves to the supported local level. +GETDEFAULTRWCONCERN_READ_CONCERN_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "read_concern_local", + command={"getDefaultRWConcern": 1, "readConcern": {"level": "local"}}, + expected={"ok": Eq(1.0)}, + msg="getDefaultRWConcern should accept a readConcern with level local", + ), + CommandTestCase( + "read_concern_empty", + command={"getDefaultRWConcern": 1, "readConcern": {}}, + expected={"ok": Eq(1.0)}, + msg="getDefaultRWConcern should accept an empty readConcern object", + ), + CommandTestCase( + "read_concern_level_null", + command={"getDefaultRWConcern": 1, "readConcern": {"level": None}}, + expected={"ok": Eq(1.0)}, + msg="getDefaultRWConcern should treat a null readConcern level as the default and succeed", + ), + CommandTestCase( + "read_concern_after_cluster_time", + command={"getDefaultRWConcern": 1, "readConcern": {"afterClusterTime": Timestamp(1, 1)}}, + expected={"ok": Eq(1.0)}, + msg="getDefaultRWConcern should accept a readConcern with afterClusterTime", + ), +] + +# Property [readConcern Type Errors]: a non-object, non-null readConcern is +# rejected by the object type check. +GETDEFAULTRWCONCERN_READ_CONCERN_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"read_concern_type_{tid}", + command={"getDefaultRWConcern": 1, "readConcern": val}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"getDefaultRWConcern should reject a {tid} readConcern as a non-object", + ) + for tid, val in [ + ("int32", 42), + ("int64", Int64(2)), + ("double", 3.14), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool", True), + ("string", "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()), + ("array", [1]), + ] +] + +# Property [readConcern level Type Errors]: a non-string, non-null readConcern +# level is rejected by the type check. +GETDEFAULTRWCONCERN_READ_CONCERN_LEVEL_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"read_concern_level_type_{tid}", + command={"getDefaultRWConcern": 1, "readConcern": {"level": val}}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"getDefaultRWConcern should reject a {tid} readConcern level as a non-string", + ) + for tid, val in [ + ("int32", 42), + ("int64", Int64(2)), + ("double", 3.14), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("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()), + ("array", ["local"]), + ("object", {"a": 1}), + ] +] + +# Property [readConcern level Value Errors]: a string readConcern level that is +# not a recognized value is rejected. +GETDEFAULTRWCONCERN_READ_CONCERN_LEVEL_VALUE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"read_concern_level_value_{tid}", + command={"getDefaultRWConcern": 1, "readConcern": {"level": val}}, + error_code=BAD_VALUE_ERROR, + msg=f"getDefaultRWConcern should reject a {tid} readConcern level value", + ) + for tid, val in [ + ("empty", ""), + ("unknown", "bogus"), + ("wrong_case", "LOCAL"), + ] +] + +# Property [Unsupported readConcern Levels]: recognized read concern levels +# other than local are rejected as unsupported. +GETDEFAULTRWCONCERN_READ_CONCERN_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"read_concern_{level}", + command={"getDefaultRWConcern": 1, "readConcern": {"level": level}}, + error_code=INVALID_OPTIONS_ERROR, + msg=f"getDefaultRWConcern should reject the {level} read concern level", + ) + for level in ["available", "majority", "linearizable", "snapshot"] +] + +# Property [readConcern atClusterTime]: atClusterTime is only valid with a +# snapshot level, so supplying it without one is rejected. +GETDEFAULTRWCONCERN_READ_CONCERN_AT_CLUSTER_TIME_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "read_concern_at_cluster_time_without_snapshot", + command={"getDefaultRWConcern": 1, "readConcern": {"atClusterTime": Timestamp(1, 1)}}, + error_code=INVALID_OPTIONS_ERROR, + msg="getDefaultRWConcern should reject atClusterTime without a snapshot level", + ), +] + +# Property [readConcern afterClusterTime Type Errors]: afterClusterTime is +# strictly typed as a timestamp, so every non-timestamp value is rejected. +GETDEFAULTRWCONCERN_READ_CONCERN_AFTER_CLUSTER_TIME_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"read_concern_after_cluster_time_type_{tid}", + command={"getDefaultRWConcern": 1, "readConcern": {"afterClusterTime": val}}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"getDefaultRWConcern should reject a {tid} afterClusterTime as a non-timestamp", + ) + for tid, val in [ + ("int32", 42), + ("int64", Int64(2)), + ("double", 3.14), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool", True), + ("string", "x"), + ("null", None), + ("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()), + ("array", [1]), + ("object", {"a": 1}), + ] +] + +# Property [readConcern afterClusterTime Value Errors]: a zero-valued (null) +# timestamp is a valid timestamp type but not a usable cluster time, so +# afterClusterTime rejects it. +GETDEFAULTRWCONCERN_READ_CONCERN_AFTER_CLUSTER_TIME_VALUE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "read_concern_after_cluster_time_null_timestamp", + command={"getDefaultRWConcern": 1, "readConcern": {"afterClusterTime": Timestamp(0, 0)}}, + error_code=INVALID_OPTIONS_ERROR, + msg="getDefaultRWConcern should reject a null timestamp afterClusterTime", + ), +] + +# Property [readConcern atClusterTime Type Errors]: atClusterTime is strictly +# typed as a timestamp, so every non-timestamp value is rejected. +GETDEFAULTRWCONCERN_READ_CONCERN_AT_CLUSTER_TIME_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"read_concern_at_cluster_time_type_{tid}", + command={"getDefaultRWConcern": 1, "readConcern": {"atClusterTime": val}}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"getDefaultRWConcern should reject a {tid} atClusterTime as a non-timestamp", + ) + for tid, val in [ + ("int32", 42), + ("int64", Int64(2)), + ("double", 3.14), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool", True), + ("string", "x"), + ("null", None), + ("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()), + ("array", [1]), + ("object", {"a": 1}), + ] +] + +# Property [readConcern Sub-Field Validation]: an unknown sub-field inside the +# readConcern document is rejected. +GETDEFAULTRWCONCERN_READ_CONCERN_UNKNOWN_FIELD_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "read_concern_unknown_subfield", + command={"getDefaultRWConcern": 1, "readConcern": {"level": "local", "unknownField": 1}}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="getDefaultRWConcern should reject an unknown readConcern sub-field", + ), +] + +GETDEFAULTRWCONCERN_READ_CONCERN_ALL_TESTS: list[CommandTestCase] = ( + GETDEFAULTRWCONCERN_READ_CONCERN_TESTS + + GETDEFAULTRWCONCERN_READ_CONCERN_TYPE_ERROR_TESTS + + GETDEFAULTRWCONCERN_READ_CONCERN_LEVEL_TYPE_ERROR_TESTS + + GETDEFAULTRWCONCERN_READ_CONCERN_LEVEL_VALUE_ERROR_TESTS + + GETDEFAULTRWCONCERN_READ_CONCERN_ERROR_TESTS + + GETDEFAULTRWCONCERN_READ_CONCERN_AT_CLUSTER_TIME_ERROR_TESTS + + GETDEFAULTRWCONCERN_READ_CONCERN_AFTER_CLUSTER_TIME_TYPE_ERROR_TESTS + + GETDEFAULTRWCONCERN_READ_CONCERN_AFTER_CLUSTER_TIME_VALUE_ERROR_TESTS + + GETDEFAULTRWCONCERN_READ_CONCERN_AT_CLUSTER_TIME_TYPE_ERROR_TESTS + + GETDEFAULTRWCONCERN_READ_CONCERN_UNKNOWN_FIELD_ERROR_TESTS +) + + +@pytest.mark.requires(cluster_admin=True) +@pytest.mark.parametrize("test", pytest_params(GETDEFAULTRWCONCERN_READ_CONCERN_ALL_TESTS)) +def test_getDefaultRWConcern_read_concern(collection, test): + """Test getDefaultRWConcern readConcern acceptance and rejection behavior.""" + 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/getDefaultRWConcern/test_getDefaultRWConcern_response.py b/documentdb_tests/compatibility/tests/system/administration/commands/getDefaultRWConcern/test_getDefaultRWConcern_response.py new file mode 100644 index 000000000..2c50a4877 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/getDefaultRWConcern/test_getDefaultRWConcern_response.py @@ -0,0 +1,139 @@ +"""Tests for getDefaultRWConcern command input acceptance and rejection behavior.""" + +import pytest + +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 ( + NOT_SUPPORTED_ON_STANDALONE_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, IsType + +# Property [Output Schema and Return Shape]: a successful response always +# returns the full defaults schema, for both an on-disk and an in-memory read. +GETDEFAULTRWCONCERN_OUTPUT_SCHEMA_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "output_schema", + command={"getDefaultRWConcern": 1}, + expected={ + "ok": Eq(1.0), + "defaultReadConcern": IsType("object"), + "defaultWriteConcern": IsType("object"), + "defaultReadConcernSource": IsType("string"), + "defaultWriteConcernSource": IsType("string"), + "localUpdateWallClockTime": IsType("date"), + }, + msg="getDefaultRWConcern should always return the read/write concern " + "defaults, their sources, and localUpdateWallClockTime", + ), + CommandTestCase( + "output_schema_in_memory", + command={"getDefaultRWConcern": 1, "inMemory": True}, + expected={ + "ok": Eq(1.0), + "inMemory": Eq(True), + "defaultReadConcern": IsType("object"), + "defaultWriteConcern": IsType("object"), + "defaultReadConcernSource": IsType("string"), + "defaultWriteConcernSource": IsType("string"), + "localUpdateWallClockTime": IsType("date"), + }, + msg="getDefaultRWConcern should return the full defaults schema for an " + "in-memory read and echo inMemory true", + ), +] + + +@pytest.mark.requires(cluster_admin=True) +@pytest.mark.parametrize("test", pytest_params(GETDEFAULTRWCONCERN_OUTPUT_SCHEMA_TESTS)) +def test_getDefaultRWConcern_output_schema(collection, test): + """Test getDefaultRWConcern always returns the full defaults schema.""" + result = execute_admin_command(collection, test.command) + assertResult( + result, + expected=test.expected, + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + + +# Property [Topology Errors]: on a standalone node the command is unsupported +# and is rejected. +@pytest.mark.requires(cluster_admin=False) +def test_getDefaultRWConcern_topology_error(collection): + """Test getDefaultRWConcern is unsupported on standalone nodes.""" + result = execute_admin_command(collection, {"getDefaultRWConcern": 1}) + assertResult( + result, + error_code=NOT_SUPPORTED_ON_STANDALONE_ERROR, + msg="getDefaultRWConcern should be unsupported on a standalone node", + raw_res=True, + ) + + +# Property [Default Concern Source Semantics]: setting a default flips the +# corresponding source from implicit to global and surfaces the update +# timestamps. This mutates global state irreversibly (the default write concern +# cannot be unset), so it runs no_parallel. +@pytest.mark.requires(cluster_admin=True) +@pytest.mark.no_parallel +def test_getDefaultRWConcern_source_global_after_set(collection): + """Test getDefaultRWConcern reports global sources once defaults are set.""" + execute_admin_command( + collection, + { + "setDefaultRWConcern": 1, + "defaultReadConcern": {"level": "local"}, + "defaultWriteConcern": {"w": "majority", "wtimeout": 0}, + }, + ) + result = execute_admin_command(collection, {"getDefaultRWConcern": 1}) + assertResult( + result, + expected={ + "ok": Eq(1.0), + "defaultReadConcernSource": Eq("global"), + "defaultWriteConcernSource": Eq("global"), + "updateOpTime": IsType("timestamp"), + "updateWallClockTime": IsType("date"), + }, + msg="getDefaultRWConcern should report global sources and present update " + "timestamps once defaults are set", + raw_res=True, + ) + + +# Property [Default Concern Source Semantics]: unsetting the default read concern +# reverts its source to implicit, while the write concern source stays global +# because it cannot be unset. This mutates global state irreversibly, so it runs +# no_parallel. +@pytest.mark.requires(cluster_admin=True) +@pytest.mark.no_parallel +def test_getDefaultRWConcern_read_source_reverts_on_unset(collection): + """Test getDefaultRWConcern reverts the read source to implicit on unset.""" + execute_admin_command( + collection, + { + "setDefaultRWConcern": 1, + "defaultReadConcern": {"level": "local"}, + "defaultWriteConcern": {"w": "majority", "wtimeout": 0}, + }, + ) + execute_admin_command(collection, {"setDefaultRWConcern": 1, "defaultReadConcern": {}}) + result = execute_admin_command(collection, {"getDefaultRWConcern": 1}) + assertResult( + result, + expected={ + "ok": Eq(1.0), + "defaultReadConcernSource": Eq("implicit"), + "defaultWriteConcernSource": Eq("global"), + }, + msg="getDefaultRWConcern should revert the read concern source to implicit " + "after unset while the write concern source remains global", + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/getDefaultRWConcern/test_getDefaultRWConcern_write_concern.py b/documentdb_tests/compatibility/tests/system/administration/commands/getDefaultRWConcern/test_getDefaultRWConcern_write_concern.py new file mode 100644 index 000000000..648d6f06c --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/getDefaultRWConcern/test_getDefaultRWConcern_write_concern.py @@ -0,0 +1,106 @@ +"""Tests for getDefaultRWConcern command input acceptance and rejection behavior.""" + +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 +from documentdb_tests.framework.test_constants import ( + DECIMAL128_ONE_AND_HALF, +) + +# Property [writeConcern Acceptance]: a null writeConcern is treated as absent +# and accepted. +GETDEFAULTRWCONCERN_WRITE_CONCERN_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "write_concern_null", + command={"getDefaultRWConcern": 1, "writeConcern": None}, + expected={"ok": Eq(1.0)}, + msg="getDefaultRWConcern should treat a null writeConcern as field-absent and succeed", + ), +] + +# Property [Unsupported writeConcern]: writeConcern is not supported, so any +# writeConcern object is rejected. +GETDEFAULTRWCONCERN_WRITE_CONCERN_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "write_concern_non_empty", + command={"getDefaultRWConcern": 1, "writeConcern": {"w": "majority"}}, + error_code=INVALID_OPTIONS_ERROR, + msg="getDefaultRWConcern should reject a non-empty writeConcern object as unsupported", + ), + CommandTestCase( + "write_concern_empty", + command={"getDefaultRWConcern": 1, "writeConcern": {}}, + error_code=INVALID_OPTIONS_ERROR, + msg="getDefaultRWConcern should reject an empty writeConcern object as unsupported", + ), +] + +# Property [writeConcern Type Errors]: a non-object writeConcern is rejected by +# the object type check. +GETDEFAULTRWCONCERN_WRITE_CONCERN_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"write_concern_type_{tid}", + command={"getDefaultRWConcern": 1, "writeConcern": val}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"getDefaultRWConcern should reject a {tid} writeConcern as a non-object", + ) + for tid, val in [ + ("int32", 42), + ("int64", Int64(2)), + ("double", 3.14), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool", True), + ("string", "majority"), + ("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()), + ("array", [1]), + ] +] + +GETDEFAULTRWCONCERN_WRITE_CONCERN_ALL_TESTS: list[CommandTestCase] = ( + GETDEFAULTRWCONCERN_WRITE_CONCERN_TESTS + + GETDEFAULTRWCONCERN_WRITE_CONCERN_ERROR_TESTS + + GETDEFAULTRWCONCERN_WRITE_CONCERN_TYPE_ERROR_TESTS +) + + +@pytest.mark.requires(cluster_admin=True) +@pytest.mark.parametrize("test", pytest_params(GETDEFAULTRWCONCERN_WRITE_CONCERN_ALL_TESTS)) +def test_getDefaultRWConcern_write_concern(collection, test): + """Test getDefaultRWConcern writeConcern acceptance and rejection behavior.""" + 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/getParameter/__init__.py b/documentdb_tests/compatibility/tests/system/administration/commands/getParameter/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/getParameter/test_getParameter_bson_type_validation.py b/documentdb_tests/compatibility/tests/system/administration/commands/getParameter/test_getParameter_bson_type_validation.py new file mode 100644 index 000000000..1e39a32e6 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/getParameter/test_getParameter_bson_type_validation.py @@ -0,0 +1,143 @@ +"""Tests for getParameter BSON type validation.""" + +import pytest + +from documentdb_tests.framework.assertions import 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, IsType, NotExists + +pytestmark = pytest.mark.admin + + +GET_PARAMETER_VALUE_PARAMS = [ + BsonTypeTestCase( + id="getParameter_value", + msg="getParameter should accept all BSON types for the command field value", + keyword="getParameter", + valid_types=list(BsonType), + valid_inputs={BsonType.OBJECT: {"showDetails": True}}, + ), +] + +COMMENT_PARAMS = [ + BsonTypeTestCase( + id="comment", + msg="getParameter should accept all BSON types for the comment field", + keyword="comment", + valid_types=list(BsonType), + ), +] + +SET_AT_PARAMS = [ + BsonTypeTestCase( + id="setAt", + msg="setAt should accept string and null", + keyword="setAt", + valid_types=[BsonType.STRING, BsonType.NULL], + requires={"allParameters": True}, + default_error_code=TYPE_MISMATCH_ERROR, + valid_inputs={BsonType.STRING: "runtime"}, + ), +] + +BSON_TYPE_PARAMS = [ + BsonTypeTestCase( + id="showDetails", + msg="showDetails should accept bool, numeric, and null", + keyword="showDetails", + valid_types=[ + BsonType.BOOL, + BsonType.INT, + BsonType.DOUBLE, + BsonType.LONG, + BsonType.DECIMAL, + BsonType.NULL, + ], + requires={"logLevel": 1}, + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="allParameters", + msg="allParameters should accept bool, numeric, and null", + keyword="allParameters", + valid_types=[ + BsonType.BOOL, + BsonType.INT, + BsonType.DOUBLE, + BsonType.LONG, + BsonType.DECIMAL, + BsonType.NULL, + ], + default_error_code=TYPE_MISMATCH_ERROR, + ), +] + + +GET_PARAMETER_VALUE_ACCEPTANCE = generate_bson_acceptance_test_cases(GET_PARAMETER_VALUE_PARAMS) + +COMMENT_ACCEPTANCE = generate_bson_acceptance_test_cases(COMMENT_PARAMS) + +SETAT_ACCEPTANCE = generate_bson_acceptance_test_cases(SET_AT_PARAMS) + +# GET_PARAMETER_VALUE_PARAMS and COMMENT_PARAMS accept all BSON types, so no rejections. +ALL_REJECTIONS = generate_bson_rejection_test_cases(SET_AT_PARAMS + BSON_TYPE_PARAMS) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", GET_PARAMETER_VALUE_ACCEPTANCE) +def test_getParameter_value_bson_type_accepted(collection, bson_type, sample_value, spec): + """Test getParameter command-field accepts all BSON types.""" + expected_type = "object" if isinstance(sample_value, dict) else "int" + result = execute_admin_command(collection, {"getParameter": sample_value, "logLevel": 1}) + assertProperties( + result, + {"ok": Eq(1.0), "logLevel": IsType(expected_type)}, + msg=f"getParameter should accept {bson_type.value}", + raw_res=True, + ) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", COMMENT_ACCEPTANCE) +def test_getParameter_comment_bson_type_accepted(collection, bson_type, sample_value, spec): + """Test comment field accepts all BSON types.""" + result = execute_admin_command( + collection, {"getParameter": 1, "logLevel": 1, "comment": sample_value} + ) + assertProperties( + result, + {"ok": Eq(1.0), "logLevel": IsType("int"), "comment": NotExists()}, + msg=f"comment should accept {bson_type.value}", + raw_res=True, + ) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", SETAT_ACCEPTANCE) +def test_getParameter_setAt_bson_type_accepted(collection, bson_type, sample_value, spec): + """Test setAt field accepts string and null.""" + result = execute_admin_command( + collection, {"getParameter": {"allParameters": True, "setAt": sample_value}} + ) + assertProperties( + result, {"ok": Eq(1.0)}, msg=f"setAt should accept {bson_type.value}", raw_res=True + ) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", ALL_REJECTIONS) +def test_getParameter_bson_type_rejected(collection, bson_type, sample_value, spec): + """Test document-form fields reject invalid BSON types.""" + from documentdb_tests.framework.assertions import assertFailureCode + + command = {"getParameter": {spec.keyword: sample_value}} + if spec.requires: + command["getParameter"].update(spec.requires) + command.update({k: v for k, v in spec.requires.items() if k not in command["getParameter"]}) + result = execute_admin_command(collection, command) + assertFailureCode( + result, spec.default_error_code, msg=f"{spec.keyword} should reject {bson_type.value}" + ) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/getParameter/test_getParameter_core_behavior.py b/documentdb_tests/compatibility/tests/system/administration/commands/getParameter/test_getParameter_core_behavior.py new file mode 100644 index 000000000..8cec9a924 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/getParameter/test_getParameter_core_behavior.py @@ -0,0 +1,348 @@ +"""Tests for getParameter command core behavior.""" + +import pytest + +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_admin_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, Exists, IsType, NotExists + +pytestmark = pytest.mark.admin + + +SINGLE_PARAM_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "int_42_with_named_param", + command={"getParameter": 42, "logLevel": 1}, + checks={"ok": Eq(1.0), "logLevel": Exists()}, + msg="Should return parameter value with any integer", + ), + DiagnosticTestCase( + "int_0_with_named_param", + command={"getParameter": 0, "logLevel": 1}, + checks={"ok": Eq(1.0), "logLevel": Exists()}, + msg="Should return parameter value with integer 0 (value is ignored)", + ), + DiagnosticTestCase( + "negative_int_with_named_param", + command={"getParameter": -1, "logLevel": 1}, + checks={"ok": Eq(1.0), "logLevel": Exists()}, + msg="Should return parameter value with negative integer", + ), + DiagnosticTestCase( + "multiple_named_params", + command={"getParameter": 1, "logLevel": 1, "quiet": 1}, + checks={"ok": Eq(1.0), "logLevel": Exists(), "quiet": Exists()}, + msg="Should return all named parameters requested in one command", + ), + DiagnosticTestCase( + "empty_doc_with_named_param", + command={"getParameter": {}, "logLevel": 1}, + checks={"ok": Eq(1.0), "logLevel": Exists()}, + msg="Empty document with a named parameter should be accepted (behaves like int form)", + ), +] + + +SHOW_DETAILS_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "showDetails_true_returns_detail_fields", + command={"getParameter": {"showDetails": True}, "logLevel": 1}, + checks={ + "ok": Eq(1.0), + "logLevel": Exists(), + "logLevel.value": Exists(), + "logLevel.settableAtRuntime": Eq(True), + "logLevel.settableAtStartup": IsType("bool"), + }, + msg="showDetails:true should return value, settableAtRuntime, settableAtStartup", + ), + DiagnosticTestCase( + "showDetails_true_allParameters", + command={"getParameter": {"showDetails": True, "allParameters": True}}, + checks={ + "ok": Eq(1.0), + "logLevel.value": Exists(), + "logLevel.settableAtRuntime": IsType("bool"), + "logLevel.settableAtStartup": IsType("bool"), + }, + msg="showDetails with allParameters should return details for all", + ), +] + + +SET_AT_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "setAt_startup", + command={"getParameter": {"allParameters": True, "setAt": "startup"}}, + checks={"ok": Eq(1.0)}, + msg="setAt:'startup' with allParameters should succeed", + ), + DiagnosticTestCase( + "setAt_runtime", + command={"getParameter": {"allParameters": True, "setAt": "runtime"}}, + checks={"ok": Eq(1.0)}, + msg="setAt:'runtime' with allParameters should succeed", + ), +] + + +PARAM_DATA_TYPE_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "param_with_int_value", + command={"getParameter": 1, "logLevel": 1}, + checks={"ok": Eq(1.0), "logLevel": IsType("int")}, + msg="Should retrieve parameter with integer value", + ), + DiagnosticTestCase( + "param_with_bool_value", + command={"getParameter": 1, "allowDiskUseByDefault": 1}, + checks={"ok": Eq(1.0), "allowDiskUseByDefault": IsType("bool")}, + msg="Should retrieve parameter with boolean value", + ), + DiagnosticTestCase( + "param_with_string_value", + command={"getParameter": 1, "ShardingTaskExecutorPoolReplicaSetMatching": 1}, + checks={"ok": Eq(1.0), "ShardingTaskExecutorPoolReplicaSetMatching": IsType("string")}, + msg="Should retrieve parameter with string value", + ), + DiagnosticTestCase( + "param_with_document_value", + command={"getParameter": 1, "featureCompatibilityVersion": 1}, + checks={"ok": Eq(1.0), "featureCompatibilityVersion": IsType("object")}, + msg="Should retrieve parameter with document value", + ), + DiagnosticTestCase( + "param_with_array_value", + command={"getParameter": 1, "authenticationMechanisms": 1}, + checks={"ok": Eq(1.0), "authenticationMechanisms": IsType("array")}, + msg="Should retrieve parameter with array value", + ), +] + + +@pytest.mark.parametrize( + "test", + pytest_params(SINGLE_PARAM_TESTS + SHOW_DETAILS_TESTS + SET_AT_TESTS + PARAM_DATA_TYPE_TESTS), +) +def test_getParameter_success_cases(collection, test): + """Test getParameter success cases: retrieval, showDetails, setAt, and value types.""" + result = execute_admin_command(collection, test.command) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) + + +def test_getParameter_allParameters_true(collection): + """Test getParameter with {allParameters: true} returns all parameters.""" + result = execute_admin_command(collection, {"getParameter": {"allParameters": True}}) + assertProperties( + result, + {"ok": Eq(1.0), "logLevel": Exists()}, + msg="Should return ok:1 and parameters", + raw_res=True, + ) + + +def test_getParameter_star_and_allParameters_consistent(collection): + """Test all parameters returned by '*' are also returned by {allParameters: true}.""" + star_result = execute_admin_command(collection, {"getParameter": "*"}) + all_result = execute_admin_command(collection, {"getParameter": {"allParameters": True}}) + star_keys = set(star_result.keys()) - {"ok"} + all_keys = set(all_result.keys()) - {"ok"} + missing = star_keys - all_keys + assertSuccess( + {"ok": 1.0, "missing": []}, + {"ok": 1.0, "missing": list(missing)}, + raw_res=True, + msg="Keys from '*' should all appear in allParameters", + ) + + +def test_getParameter_showDetails_false(collection): + """Test {showDetails: false} returns parameter value without detail fields.""" + result = execute_admin_command( + collection, {"getParameter": {"showDetails": False}, "logLevel": 1} + ) + assertProperties( + result, + { + "ok": Eq(1.0), + "logLevel": Exists(), + "logLevel.value": NotExists(), + "logLevel.settableAtRuntime": NotExists(), + "logLevel.settableAtStartup": NotExists(), + }, + msg="showDetails:false should return the plain value without detail fields", + raw_res=True, + ) + + +def test_getParameter_setAt_without_allParameters_succeeds(collection): + """Test setAt without allParameters succeeds (setAt is ignored for single param).""" + result = execute_admin_command( + collection, {"getParameter": {"setAt": "startup"}, "logLevel": 1} + ) + assertProperties( + result, + {"ok": Eq(1.0), "logLevel": Exists()}, + msg="setAt without allParameters should succeed", + raw_res=True, + ) + + +def test_getParameter_star_response_has_multiple_params(collection): + """Test '*' response contains multiple parameter fields.""" + result = execute_admin_command(collection, {"getParameter": "*"}) + param_keys = set(result.keys()) - {"ok"} + assertSuccess( + {"ok": 1.0, "has_logLevel": True, "has_multiple_params": True}, + { + "ok": 1.0, + "has_logLevel": "logLevel" in param_keys, + "has_multiple_params": len(param_keys) > 1, + }, + raw_res=True, + msg=f"Star should return multiple params including logLevel, got {len(param_keys)}", + ) + + +def test_getParameter_single_matches_star(collection): + """Test parameter value from single retrieval matches value in '*' response.""" + single = execute_admin_command(collection, {"getParameter": 1, "logLevel": 1}) + star = execute_admin_command(collection, {"getParameter": "*"}) + assertSuccess( + {"ok": 1.0, "logLevel": single["logLevel"]}, + {"ok": 1.0, "logLevel": star["logLevel"]}, + raw_res=True, + msg="Single retrieval should match star value", + ) + + +@pytest.mark.no_parallel +def test_getParameter_reflects_setParameter_change(collection): + """Test getParameter returns updated value after setParameter modifies a runtime parameter.""" + try: + execute_admin_command(collection, {"setParameter": 1, "logLevel": 2}) + result = execute_admin_command(collection, {"getParameter": 1, "logLevel": 1}) + assertProperties( + result, {"logLevel": Eq(2)}, msg="Should reflect setParameter change", raw_res=True + ) + finally: + execute_admin_command(collection, {"setParameter": 1, "logLevel": 0}) + + +def test_getParameter_known_startup_only_param(collection): + """Test retrieval of a known startup-only parameter (KeysRotationIntervalSec).""" + result = execute_admin_command( + collection, {"getParameter": {"showDetails": True}, "KeysRotationIntervalSec": 1} + ) + assertProperties( + result, + { + "KeysRotationIntervalSec.settableAtRuntime": Eq(False), + "KeysRotationIntervalSec.settableAtStartup": Eq(True), + }, + msg="Startup-only param should have settableAtRuntime:false", + raw_res=True, + ) + + +def test_getParameter_fully_immutable_param(collection): + """Test a fully-immutable parameter has both settability flags false.""" + result = execute_admin_command( + collection, {"getParameter": {"showDetails": True}, "authSchemaVersion": 1} + ) + assertProperties( + result, + { + "authSchemaVersion.settableAtRuntime": Eq(False), + "authSchemaVersion.settableAtStartup": Eq(False), + }, + msg="Fully-immutable param should have both settability flags false", + raw_res=True, + ) + + +def test_getParameter_setAt_filters_result_count(collection): + """Test setAt actually narrows the result set.""" + all_keys = set(execute_admin_command(collection, {"getParameter": {"allParameters": True}})) + runtime_keys = set( + execute_admin_command( + collection, {"getParameter": {"allParameters": True, "setAt": "runtime"}} + ) + ) + startup_keys = set( + execute_admin_command( + collection, {"getParameter": {"allParameters": True, "setAt": "startup"}} + ) + ) + n_all = len(all_keys - {"ok"}) + assertSuccess( + {"ok": 1.0, "runtime_filtered": True, "startup_filtered": True}, + { + "ok": 1.0, + "runtime_filtered": len(runtime_keys - {"ok"}) < n_all, + "startup_filtered": len(startup_keys - {"ok"}) < n_all, + }, + raw_res=True, + msg="setAt:'runtime' and setAt:'startup' should each return fewer params than unfiltered", + ) + + +def test_getParameter_setAt_buckets_startup_only_param(collection): + """Test setAt routes a startup-only param to the correct bucket.""" + runtime_keys = set( + execute_admin_command( + collection, {"getParameter": {"allParameters": True, "setAt": "runtime"}} + ) + ) + startup_keys = set( + execute_admin_command( + collection, {"getParameter": {"allParameters": True, "setAt": "startup"}} + ) + ) + assertSuccess( + {"ok": 1.0, "in_runtime": False, "in_startup": True}, + { + "ok": 1.0, + "in_runtime": "KeysRotationIntervalSec" in runtime_keys, + "in_startup": "KeysRotationIntervalSec" in startup_keys, + }, + raw_res=True, + msg="Startup-only param should appear only under setAt:'startup'", + ) + + +def test_getParameter_allParameters_with_named_param_returns_all(collection): + """Test {allParameters: true} with a named param returns the same keys as allParameters.""" + all_only = execute_admin_command(collection, {"getParameter": {"allParameters": True}}) + with_named = execute_admin_command( + collection, {"getParameter": {"allParameters": True}, "logLevel": 1} + ) + all_only_keys = set(all_only.keys()) - {"ok"} + with_named_keys = set(with_named.keys()) - {"ok"} + assertSuccess( + {"key_diff": []}, + {"key_diff": sorted(all_only_keys ^ with_named_keys)}, + raw_res=True, + msg="allParameters with a named param should return the same keys as allParameters alone", + ) + + +def test_getParameter_allParameters_with_named_param_not_filtered(collection): + """Test {allParameters: true} with a named param is not narrowed to just that param.""" + result = execute_admin_command( + collection, {"getParameter": {"allParameters": True}, "logLevel": 1} + ) + param_keys = set(result.keys()) - {"ok"} + assertSuccess( + {"has_logLevel": True, "has_other_params": True}, + { + "has_logLevel": "logLevel" in param_keys, + "has_other_params": len(param_keys) > 1, + }, + raw_res=True, + msg="allParameters with a named param should still return all params, not just logLevel", + ) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/getParameter/test_getParameter_errors.py b/documentdb_tests/compatibility/tests/system/administration/commands/getParameter/test_getParameter_errors.py new file mode 100644 index 000000000..fd2692375 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/getParameter/test_getParameter_errors.py @@ -0,0 +1,169 @@ +"""Tests for getParameter command error cases.""" + +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 ( + BAD_VALUE_ERROR, + COMMAND_NOT_FOUND_ERROR, + INVALID_OPTIONS_ERROR, + TYPE_MISMATCH_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 + +pytestmark = pytest.mark.admin + + +PARAM_NAME_ERROR_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "empty_string_param_name", + command={"getParameter": 1, "": 1}, + error_code=INVALID_OPTIONS_ERROR, + msg="Should reject empty string parameter name", + ), + DiagnosticTestCase( + "special_chars_param_name", + command={"getParameter": 1, "!@#$%": 1}, + error_code=INVALID_OPTIONS_ERROR, + msg="Should reject parameter name with special characters", + ), + DiagnosticTestCase( + "very_long_param_name", + command={"getParameter": 1, "a" * 1000: 1}, + error_code=INVALID_OPTIONS_ERROR, + msg="Should reject very long parameter name", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(PARAM_NAME_ERROR_TESTS)) +def test_getParameter_param_name_errors(collection, test): + """Test getParameter rejects invalid parameter names.""" + result = execute_admin_command(collection, test.command) + assertFailureCode(result, test.error_code, msg=test.msg) + + +SUBFIELD_TYPE_ERROR_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "showDetails_non_bool", + command={"getParameter": {"showDetails": "yes"}, "logLevel": 1}, + error_code=TYPE_MISMATCH_ERROR, + msg="Should reject non-boolean showDetails", + ), + DiagnosticTestCase( + "allParameters_non_bool", + command={"getParameter": {"allParameters": "yes"}}, + error_code=TYPE_MISMATCH_ERROR, + msg="Should reject non-boolean allParameters", + ), + DiagnosticTestCase( + "setAt_non_string", + command={"getParameter": {"allParameters": True, "setAt": 123}}, + error_code=TYPE_MISMATCH_ERROR, + msg="Should reject non-string setAt", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(SUBFIELD_TYPE_ERROR_TESTS)) +def test_getParameter_subfield_type_errors(collection, test): + """Test getParameter rejects wrong BSON types for document-form sub-fields.""" + result = execute_admin_command(collection, test.command) + assertFailureCode(result, test.error_code, msg=test.msg) + + +SELECTOR_ERROR_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "arbitrary_string_not_star", + command={"getParameter": "foo"}, + error_code=INVALID_OPTIONS_ERROR, + msg="Non-'*' string value should be rejected as a parameter name", + ), + DiagnosticTestCase( + "int_form_no_param_name", + command={"getParameter": 1}, + error_code=INVALID_OPTIONS_ERROR, + msg="Integer form without a named parameter should fail", + ), + DiagnosticTestCase( + "empty_doc_no_param_name", + command={"getParameter": {}}, + error_code=INVALID_OPTIONS_ERROR, + msg="Empty document without a named parameter should fail", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(SELECTOR_ERROR_TESTS)) +def test_getParameter_selector_errors(collection, test): + """Test getParameter rejects invalid or missing parameter selectors.""" + result = execute_admin_command(collection, test.command) + assertFailureCode(result, test.error_code, msg=test.msg) + + +def test_getParameter_on_non_admin_db_fails(collection): + """Test getParameter run on a non-admin database fails with Unauthorized.""" + result = execute_command(collection, {"getParameter": 1, "logLevel": 1}) + assertFailureCode(result, UNAUTHORIZED_ERROR, msg="Non-admin DB should fail") + + +def test_getParameter_param_name_case_sensitive(collection): + """Test that parameter names are case-sensitive.""" + result = execute_admin_command(collection, {"getParameter": 1, "LOGLEVEL": 1}) + assertFailureCode( + result, INVALID_OPTIONS_ERROR, msg="Case-mismatched parameter name should fail" + ) + + +def test_getParameter_unrecognized_field_treated_as_param(collection): + """Test an unknown top-level field is treated as a non-existent parameter name.""" + result = execute_admin_command(collection, {"getParameter": 1, "unknownField": 1}) + assertFailureCode( + result, + INVALID_OPTIONS_ERROR, + msg="Unknown field alone treated as non-existent parameter", + ) + + +def test_getParameter_doc_form_unrecognized_field_fails(collection): + """Test the document form with unrecognized fields fails.""" + result = execute_admin_command( + collection, {"getParameter": {"unknownField": True}, "logLevel": 1} + ) + assertFailureCode( + result, UNRECOGNIZED_COMMAND_FIELD_ERROR, msg="Unrecognized doc field should fail" + ) + + +def test_getParameter_no_getParameter_field_fails(collection): + """Test a command without the getParameter field fails with CommandNotFound.""" + result = execute_admin_command(collection, {"logLevel": 1}) + assertFailureCode(result, COMMAND_NOT_FOUND_ERROR, msg="Missing getParameter field should fail") + + +def test_getParameter_command_field_not_first_fails(collection): + """Test the command fails when getParameter is not the first field.""" + result = execute_admin_command(collection, {"logLevel": 1, "getParameter": 1}) + assertFailureCode(result, COMMAND_NOT_FOUND_ERROR, msg="Command field not first should fail") + + +def test_getParameter_setAt_invalid_value(collection): + """Test setAt with an invalid value fails with BadValue.""" + result = execute_admin_command( + collection, {"getParameter": {"allParameters": True, "setAt": "invalid"}} + ) + assertFailureCode(result, BAD_VALUE_ERROR, msg="Invalid setAt value should fail") + + +def test_getParameter_setAt_empty_string(collection): + """Test setAt with an empty string fails with BadValue, not treated as no filter.""" + result = execute_admin_command( + collection, {"getParameter": {"allParameters": True, "setAt": ""}} + ) + assertFailureCode(result, BAD_VALUE_ERROR, msg="Empty-string setAt value should fail") diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/killOp/__init__.py b/documentdb_tests/compatibility/tests/system/administration/commands/killOp/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/killOp/test_killOp_bson_type_validation.py b/documentdb_tests/compatibility/tests/system/administration/commands/killOp/test_killOp_bson_type_validation.py new file mode 100644 index 000000000..c9b8b5f5b --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/killOp/test_killOp_bson_type_validation.py @@ -0,0 +1,114 @@ +"""Tests for killOp BSON type validation.""" + +import pytest +from bson import Decimal128, Int64 + +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, NotExists +from documentdb_tests.framework.test_constants import NON_RUNNING_OP_ID + +pytestmark = [pytest.mark.admin, pytest.mark.no_parallel] + + +KILLOP_VALUE_PARAM = [ + BsonTypeTestCase( + id="killOp_value", + msg="killOp should accept all BSON types for the command field value", + keyword="killOp", + valid_types=list(BsonType), + requires={"op": NON_RUNNING_OP_ID}, + ), +] + +COMMENT_PARAM = [ + BsonTypeTestCase( + id="comment", + msg="killOp should accept all BSON types for the comment field", + keyword="comment", + valid_types=list(BsonType), + requires={"op": NON_RUNNING_OP_ID}, + ), +] + +OP_VALUE_PARAM = [ + BsonTypeTestCase( + id="op", + msg="op field should accept numeric types only", + keyword="op", + valid_types=[ + BsonType.INT, + BsonType.DOUBLE, + BsonType.LONG, + BsonType.DECIMAL, + ], + valid_inputs={ + BsonType.DOUBLE: 999999.0, + BsonType.LONG: Int64(999999), + BsonType.DECIMAL: Decimal128("999999"), + }, + default_error_code=TYPE_MISMATCH_ERROR, + ), +] + +# killOp and comment accept all BSON types, so they have acceptance cases only (no rejections). +KILLOP_ACCEPTANCE = generate_bson_acceptance_test_cases(KILLOP_VALUE_PARAM) +COMMENT_ACCEPTANCE = generate_bson_acceptance_test_cases(COMMENT_PARAM) +OP_ACCEPTANCE = generate_bson_acceptance_test_cases(OP_VALUE_PARAM) +OP_REJECTIONS = generate_bson_rejection_test_cases(OP_VALUE_PARAM) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", KILLOP_ACCEPTANCE) +def test_killOp_bson_type_accepted(collection, bson_type, sample_value, spec): + """Test killOp accepts valid BSON types for the killOp command field.""" + result = execute_admin_command(collection, {"killOp": sample_value, "op": NON_RUNNING_OP_ID}) + assertProperties( + result, + {"ok": Eq(1.0)}, + msg=f"{spec.msg} (bson_type={bson_type.value})", + raw_res=True, + ) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", COMMENT_ACCEPTANCE) +def test_killOp_comment_bson_type_accepted(collection, bson_type, sample_value, spec): + """Test comment field accepts all BSON types and is consumed (not echoed back).""" + result = execute_admin_command( + collection, {"killOp": 1, "comment": sample_value, "op": NON_RUNNING_OP_ID} + ) + assertProperties( + result, + {"ok": Eq(1.0), "comment": NotExists()}, + msg=f"{spec.msg} (bson_type={bson_type.value})", + raw_res=True, + ) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", OP_ACCEPTANCE) +def test_killOp_op_bson_type_accepted(collection, bson_type, sample_value, spec): + """Test op field accepts the numeric BSON types.""" + result = execute_admin_command(collection, {"killOp": 1, "op": sample_value}) + assertProperties( + result, + {"ok": Eq(1.0)}, + msg=f"{spec.msg} (bson_type={bson_type.value})", + raw_res=True, + ) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", OP_REJECTIONS) +def test_killOp_op_bson_type_rejected(collection, bson_type, sample_value, spec): + """Test op field rejects non-numeric BSON types.""" + result = execute_admin_command(collection, {"killOp": 1, "op": sample_value}) + assertFailureCode( + result, + spec.expected_code(bson_type), + msg=f"killOp should reject {bson_type.value} for op field", + ) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/killOp/test_killOp_core_behavior.py b/documentdb_tests/compatibility/tests/system/administration/commands/killOp/test_killOp_core_behavior.py new file mode 100644 index 000000000..f04430aa7 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/killOp/test_killOp_core_behavior.py @@ -0,0 +1,90 @@ +"""Tests for killOp command core behavior. + +Validates killOp response structure, behavior with non-existent operations, +and edge cases for opId values. +""" + +import pytest + +from documentdb_tests.framework.assertions import assertSuccessPartial +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.test_constants import ( + INT32_MAX, + INT32_MIN, + INT32_ZERO, + NON_RUNNING_OP_ID, +) + +pytestmark = [pytest.mark.admin, pytest.mark.no_parallel] + + +def test_killOp_nonexistent_opid_returns_ok(collection): + """Test killOp with opId that does not correspond to any running operation returns ok:1.""" + result = execute_admin_command(collection, {"killOp": 1, "op": NON_RUNNING_OP_ID}) + assertSuccessPartial( + result, + {"ok": 1.0, "info": "attempting to kill op"}, + msg="Should return ok:1 for non-existent opId", + ) + + +def test_killOp_op_int32_min(collection): + """Test killOp with minimum int32 opId returns ok:1.""" + result = execute_admin_command(collection, {"killOp": 1, "op": INT32_MIN}) + assertSuccessPartial( + result, + {"ok": 1.0, "info": "attempting to kill op"}, + msg="Should return ok:1 for INT32_MIN opId", + ) + + +def test_killOp_op_zero(collection): + """Test killOp with zero opId returns ok:1.""" + result = execute_admin_command(collection, {"killOp": 1, "op": INT32_ZERO}) + assertSuccessPartial( + result, + {"ok": 1.0, "info": "attempting to kill op"}, + msg="Should return ok:1 for opId 0", + ) + + +def test_killOp_op_int32_max(collection): + """Test killOp with maximum int32 opId returns ok:1.""" + result = execute_admin_command(collection, {"killOp": 1, "op": INT32_MAX}) + assertSuccessPartial( + result, + {"ok": 1.0, "info": "attempting to kill op"}, + msg="Should return ok:1 for INT32_MAX opId", + ) + + +def test_killOp_op_int32_max_as_double(collection): + """Test killOp accepts INT32_MAX as a whole-number double.""" + result = execute_admin_command(collection, {"killOp": 1, "op": float(INT32_MAX)}) + assertSuccessPartial( + result, + {"ok": 1.0, "info": "attempting to kill op"}, + msg="Should return ok:1 for INT32_MAX as a double", + ) + + +def test_killOp_op_int32_min_as_double(collection): + """Test killOp accepts INT32_MIN as a whole-number double.""" + result = execute_admin_command(collection, {"killOp": 1, "op": float(INT32_MIN)}) + assertSuccessPartial( + result, + {"ok": 1.0, "info": "attempting to kill op"}, + msg="Should return ok:1 for INT32_MIN as a double", + ) + + +def test_killOp_extra_unrecognized_field(collection): + """Test killOp with unrecognized top-level field is accepted (not rejected).""" + result = execute_admin_command( + collection, {"killOp": 1, "op": NON_RUNNING_OP_ID, "unknownField": 1} + ) + assertSuccessPartial( + result, + {"ok": 1.0, "info": "attempting to kill op"}, + msg="Should ignore unrecognized fields", + ) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/killOp/test_killOp_errors.py b/documentdb_tests/compatibility/tests/system/administration/commands/killOp/test_killOp_errors.py new file mode 100644 index 000000000..43f3dca5b --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/killOp/test_killOp_errors.py @@ -0,0 +1,117 @@ +"""Tests for killOp command error cases. + +Consolidates all failure-asserting test cases for the killOp command: +non-admin database rejection, missing op field, and invalid op values +(non-integral, or a valid integer outside the int32 range). +""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + KILLOP_OPID_NOT_INT32_ERROR, + NO_SUCH_KEY_ERROR, + UNAUTHORIZED_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.test_constants import ( + INT32_OVERFLOW, + INT32_UNDERFLOW, + INT64_MAX, + INT64_MIN, + NON_RUNNING_OP_ID, +) + +pytestmark = [pytest.mark.admin, pytest.mark.no_parallel] + + +def test_killOp_op_fractional_double_rejected(collection): + """Test killOp rejects a fractional double op value.""" + result = execute_admin_command(collection, {"killOp": 1, "op": 3.14}) + assertFailureCode(result, BAD_VALUE_ERROR, msg="killOp should reject a non-integral op value") + + +def test_killOp_op_fractional_decimal_rejected(collection): + """Test killOp rejects a fractional decimal op value.""" + result = execute_admin_command(collection, {"killOp": 1, "op": Decimal128("3.14")}) + assertFailureCode(result, BAD_VALUE_ERROR, msg="killOp should reject a non-integral op value") + + +def test_killOp_op_nearly_int_double_rejected(collection): + """Test killOp rejects a double that is very close to but not an integer.""" + result = execute_admin_command(collection, {"killOp": 1, "op": 0.9999999999999999}) + assertFailureCode(result, BAD_VALUE_ERROR, msg="killOp should reject a non-integral op value") + + +def test_killOp_op_double_above_int32_max_rejected(collection): + """Test killOp rejects a double above the int32 range.""" + result = execute_admin_command(collection, {"killOp": 1, "op": float(INT32_OVERFLOW)}) + assertFailureCode( + result, + KILLOP_OPID_NOT_INT32_ERROR, + msg="killOp should reject an op value outside the int32 range", + ) + + +def test_killOp_op_long_above_int32_max_rejected(collection): + """Test killOp rejects a long above the int32 range.""" + result = execute_admin_command(collection, {"killOp": 1, "op": Int64(INT32_OVERFLOW)}) + assertFailureCode( + result, + KILLOP_OPID_NOT_INT32_ERROR, + msg="killOp should reject an op value outside the int32 range", + ) + + +def test_killOp_op_long_below_int32_min_rejected(collection): + """Test killOp rejects a long below the int32 range.""" + result = execute_admin_command(collection, {"killOp": 1, "op": Int64(INT32_UNDERFLOW)}) + assertFailureCode( + result, + KILLOP_OPID_NOT_INT32_ERROR, + msg="killOp should reject an op value outside the int32 range", + ) + + +def test_killOp_op_int64_max_rejected(collection): + """Test killOp rejects INT64_MAX (outside the int32 range).""" + result = execute_admin_command(collection, {"killOp": 1, "op": INT64_MAX}) + assertFailureCode( + result, + KILLOP_OPID_NOT_INT32_ERROR, + msg="killOp should reject an op value outside the int32 range", + ) + + +def test_killOp_op_int64_min_rejected(collection): + """Test killOp rejects INT64_MIN (outside the int32 range).""" + result = execute_admin_command(collection, {"killOp": 1, "op": INT64_MIN}) + assertFailureCode( + result, + KILLOP_OPID_NOT_INT32_ERROR, + msg="killOp should reject an op value outside the int32 range", + ) + + +def test_killOp_op_decimal_above_int32_max_rejected(collection): + """Test killOp rejects a decimal above the int32 range.""" + result = execute_admin_command(collection, {"killOp": 1, "op": Decimal128(str(INT32_OVERFLOW))}) + assertFailureCode( + result, + KILLOP_OPID_NOT_INT32_ERROR, + msg="killOp should reject an op value outside the int32 range", + ) + + +def test_killOp_on_non_admin_database_fails(collection): + """Test killOp run against non-admin database fails with error.""" + result = execute_command(collection, {"killOp": 1, "op": NON_RUNNING_OP_ID}) + assertFailureCode(result, UNAUTHORIZED_ERROR, msg="Should fail on non-admin database") + + +def test_killOp_missing_op_field(collection): + """Test killOp without op field fails with error.""" + result = execute_admin_command(collection, {"killOp": 1}) + assertFailureCode(result, NO_SUCH_KEY_ERROR, msg="Should fail without op field") diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/logRotate/__init__.py b/documentdb_tests/compatibility/tests/system/administration/commands/logRotate/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/logRotate/test_logRotate_bson_type_validation.py b/documentdb_tests/compatibility/tests/system/administration/commands/logRotate/test_logRotate_bson_type_validation.py new file mode 100644 index 000000000..a41ea2222 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/logRotate/test_logRotate_bson_type_validation.py @@ -0,0 +1,99 @@ +"""Tests for logRotate BSON type validation. + +The value field accepts numeric/bool types and the string "server"; other +types are rejected with TypeMismatch. The comment field accepts all BSON +types and is consumed by the server rather than echoed back in the response. +""" + +import pytest + +from documentdb_tests.framework.assertions import ( + assertFailureCode, + assertProperties, + 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 FILE_RENAME_FAILED_ERROR, TYPE_MISMATCH_ERROR +from documentdb_tests.framework.executor import ( + execute_admin_command, + execute_admin_with_retry_command, +) +from documentdb_tests.framework.property_checks import Eq, NotExists + +pytestmark = [pytest.mark.admin, pytest.mark.no_parallel] + + +LOG_ROTATE_VALUE_PARAMS = [ + BsonTypeTestCase( + id="logRotate_value", + msg="logRotate value should accept numeric, bool, and the 'server' string", + keyword="logRotate", + valid_types=[ + BsonType.INT, + BsonType.DOUBLE, + BsonType.LONG, + BsonType.BOOL, + BsonType.DECIMAL, + BsonType.STRING, + ], + valid_inputs={BsonType.STRING: "server"}, + default_error_code=TYPE_MISMATCH_ERROR, + ), +] + +COMMENT_PARAMS = [ + BsonTypeTestCase( + id="comment", + msg="logRotate should accept all BSON types for the comment field", + keyword="comment", + valid_types=list(BsonType), + ), +] + + +LOG_ROTATE_VALUE_ACCEPTANCE = generate_bson_acceptance_test_cases(LOG_ROTATE_VALUE_PARAMS) +LOG_ROTATE_VALUE_REJECTIONS = generate_bson_rejection_test_cases(LOG_ROTATE_VALUE_PARAMS) +COMMENT_ACCEPTANCE = generate_bson_acceptance_test_cases(COMMENT_PARAMS) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", LOG_ROTATE_VALUE_ACCEPTANCE) +def test_logRotate_value_bson_type_accepted(collection, bson_type, sample_value, spec): + """Test logRotate accepts numeric, bool, and the 'server' string value.""" + result = execute_admin_with_retry_command( + collection, {"logRotate": sample_value}, retry_code=FILE_RENAME_FAILED_ERROR + ) + assertSuccessPartial( + result, + {"ok": 1.0}, + msg=f"{spec.msg} (bson_type={bson_type.value})", + ) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", COMMENT_ACCEPTANCE) +def test_logRotate_comment_bson_type_accepted(collection, bson_type, sample_value, spec): + """Test comment field accepts all BSON types and is consumed, not echoed back.""" + result = execute_admin_with_retry_command( + collection, {"logRotate": 1, "comment": sample_value}, retry_code=FILE_RENAME_FAILED_ERROR + ) + assertProperties( + result, + {"ok": Eq(1.0), "comment": NotExists()}, + msg=f"comment should be accepted and not echoed back ({bson_type.value})", + raw_res=True, + ) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", LOG_ROTATE_VALUE_REJECTIONS) +def test_logRotate_value_bson_type_rejected(collection, bson_type, sample_value, spec): + """Test logRotate value rejects non-numeric, non-string BSON types.""" + result = execute_admin_command(collection, {"logRotate": sample_value}) + assertFailureCode( + result, + spec.expected_code(bson_type), + msg=f"logRotate should reject {bson_type.value} for the command value", + ) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/logRotate/test_logRotate_errors.py b/documentdb_tests/compatibility/tests/system/administration/commands/logRotate/test_logRotate_errors.py new file mode 100644 index 000000000..8e7a2aa33 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/logRotate/test_logRotate_errors.py @@ -0,0 +1,86 @@ +"""Tests for logRotate command error cases.""" + +import pytest + +from documentdb_tests.compatibility.tests.system.administration.utils.admin_test_case import ( + AdminTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import ( + NO_SUCH_KEY_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 + +pytestmark = [pytest.mark.admin, pytest.mark.no_parallel] + + +INVALID_STRING_TESTS: list[AdminTestCase] = [ + AdminTestCase( + "invalid_string", + command={"logRotate": "invalid"}, + error_code=NO_SUCH_KEY_ERROR, + msg="Should reject invalid string value", + ), + AdminTestCase( + "empty_string", + command={"logRotate": ""}, + error_code=NO_SUCH_KEY_ERROR, + msg="Should reject empty string", + ), + AdminTestCase( + "case_sensitive_SERVER", + command={"logRotate": "SERVER"}, + error_code=NO_SUCH_KEY_ERROR, + msg="Should reject uppercase SERVER (case-sensitive)", + ), + AdminTestCase( + "case_sensitive_Audit", + command={"logRotate": "Audit"}, + error_code=NO_SUCH_KEY_ERROR, + msg="Should reject mixed-case Audit (case-sensitive)", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(INVALID_STRING_TESTS)) +def test_logRotate_invalid_arguments(collection, test): + """Test that logRotate rejects invalid string values.""" + result = execute_admin_command(collection, test.command) + assertFailureCode(result, test.error_code, msg=test.msg) + + +def test_logRotate_unrecognized_field(collection): + """Test that logRotate rejects unrecognized top-level fields.""" + result = execute_admin_command(collection, {"logRotate": 1, "unknownField": 1}) + assertFailureCode( + result, UNRECOGNIZED_COMMAND_FIELD_ERROR, msg="Should reject unrecognized fields" + ) + + +def test_logRotate_unrecognized_field_with_comment(collection): + """Test that an unrecognized field still fails when a valid comment is present.""" + result = execute_admin_command( + collection, {"logRotate": 1, "comment": "hello", "unknownField": 1} + ) + assertFailureCode( + result, + UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="Should reject unrecognized fields even when a valid comment is present", + ) + + +def test_logRotate_non_admin_database(collection): + """Test that logRotate fails when run on a non-admin database.""" + result = execute_command(collection, {"logRotate": 1}) + assertFailureCode(result, UNAUTHORIZED_ERROR, msg="Should fail on non-admin database") + + +def test_logRotate_audit_target_rejected_when_disabled(collection): + """Test that the 'audit' log target is rejected when auditing is disabled.""" + result = execute_admin_command(collection, {"logRotate": "audit"}) + assertFailureCode( + result, NO_SUCH_KEY_ERROR, msg="Should reject audit target when auditing is disabled" + ) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/logRotate/test_logRotate_value_acceptance.py b/documentdb_tests/compatibility/tests/system/administration/commands/logRotate/test_logRotate_value_acceptance.py new file mode 100644 index 000000000..40b414a65 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/logRotate/test_logRotate_value_acceptance.py @@ -0,0 +1,37 @@ +"""Tests for logRotate acceptance of specific scalar values. + +Covers values the shared BSON-type harness does not sample: boolean `false`, `0`, +and negative integers/longs (it only feeds `True` and `INT32_MAX`/`INT64_MAX`). +Each rotation goes through `execute_admin_with_retry_command`, which retries past the +transient FileRenameFailed so the test can assert a clean success. +""" + +import pytest +from bson.int64 import Int64 + +from documentdb_tests.framework.assertions import assertSuccessPartial +from documentdb_tests.framework.error_codes import FILE_RENAME_FAILED_ERROR +from documentdb_tests.framework.executor import execute_admin_with_retry_command + +pytestmark = [pytest.mark.admin, pytest.mark.no_parallel] + + +def test_logRotate_value_bool_false_accepted(collection): + """Test logRotate accepts boolean false (the BSON sample only covers true).""" + result = execute_admin_with_retry_command( + collection, {"logRotate": False}, retry_code=FILE_RENAME_FAILED_ERROR + ) + assertSuccessPartial(result, {"ok": 1.0}, msg="logRotate value should accept boolean false") + + +@pytest.mark.parametrize( + "value", + [0, -1, Int64(-5)], + ids=["zero", "negative_int", "negative_long"], +) +def test_logRotate_value_zero_and_negative_accepted(collection, value): + """Test logRotate accepts 0 and negative integers (BSON samples only cover max values).""" + result = execute_admin_with_retry_command( + collection, {"logRotate": value}, retry_code=FILE_RENAME_FAILED_ERROR + ) + assertSuccessPartial(result, {"ok": 1.0}, msg=f"logRotate value should accept {value!r}") diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/logRotate/test_smoke_logRotate.py b/documentdb_tests/compatibility/tests/system/administration/commands/logRotate/test_smoke_logRotate.py index f3916594a..30f250b88 100644 --- a/documentdb_tests/compatibility/tests/system/administration/commands/logRotate/test_smoke_logRotate.py +++ b/documentdb_tests/compatibility/tests/system/administration/commands/logRotate/test_smoke_logRotate.py @@ -1,20 +1,17 @@ -""" -Smoke test for logRotate command. - -Tests basic logRotate functionality. -""" +"""Smoke test for logRotate command.""" import pytest from documentdb_tests.framework.assertions import assertSuccessPartial -from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.error_codes import FILE_RENAME_FAILED_ERROR +from documentdb_tests.framework.executor import execute_admin_with_retry_command pytestmark = [pytest.mark.smoke, pytest.mark.no_parallel] def test_smoke_logRotate(collection): """Test basic logRotate behavior.""" - result = execute_admin_command(collection, {"logRotate": 1}) - - expected = {"ok": 1.0} - assertSuccessPartial(result, expected, msg="Should support logRotate command") + result = execute_admin_with_retry_command( + collection, {"logRotate": 1}, retry_code=FILE_RENAME_FAILED_ERROR + ) + assertSuccessPartial(result, {"ok": 1.0}, msg="Should support logRotate command") diff --git a/documentdb_tests/compatibility/tests/system/administration/utils/__init__.py b/documentdb_tests/compatibility/tests/system/administration/utils/__init__.py new file mode 100644 index 000000000..e69de29bb 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 new file mode 100644 index 000000000..1780e1416 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/utils/admin_test_case.py @@ -0,0 +1,22 @@ +"""Shared test case for administration command tests.""" + +from dataclasses import dataclass +from typing import Any, Dict, Optional + +from documentdb_tests.framework.test_case import BaseTestCase + + +@dataclass(frozen=True) +class AdminTestCase(BaseTestCase): + """Test case for administration command tests. + + Inherits ``id``, ``expected``, ``error_code``, ``msg``, and ``marks`` from + ``BaseTestCase``. + + Attributes: + command: The command document to execute. + use_admin: If True, execute against the admin database. + """ + + command: Optional[Dict[str, Any]] = None + use_admin: bool = True diff --git a/documentdb_tests/compatibility/tests/system/administration/utils/fsync_lock.py b/documentdb_tests/compatibility/tests/system/administration/utils/fsync_lock.py new file mode 100644 index 000000000..219c3ed22 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/utils/fsync_lock.py @@ -0,0 +1,40 @@ +"""Shared fsync-lock state management for fsync and fsyncUnlock tests. + +The fsync lock is a server-global count that neither command's collection +fixture manages, so both test directories need a way to return the server to a +known unlocked baseline. The drain helper and the autouse baseline fixture here +are the single source of truth; each command directory's conftest re-exports the +fixture so it applies to that directory's tests. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.framework.error_codes import ILLEGAL_OPERATION_ERROR +from documentdb_tests.framework.executor import execute_admin_command + + +def drain_fsync_lock(collection) -> None: + """Release outstanding fsync locks until the server-global count reaches 0. + + Loops fsyncUnlock until the reported count is 0, treating the not-locked + error as "already 0" and re-raising any other error so an unexpected failure + cannot silently leave the server locked. + """ + while True: + result = execute_admin_command(collection, {"fsyncUnlock": 1}) + if isinstance(result, Exception): + if getattr(result, "code", None) == ILLEGAL_OPERATION_ERROR: + return + raise result + if result.get("lockCount", 0) == 0: + return + + +@pytest.fixture(autouse=True) +def unlocked_baseline(collection): + """Drain the server-global fsync lock before and after every test.""" + drain_fsync_lock(collection) + yield + drain_fsync_lock(collection) 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 843ef82dc..8b84638d2 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 @@ -38,10 +38,8 @@ def test_buildInfo_same_result_any_database(collection): def test_buildInfo_nonexistent_database(collection): - """Test buildInfo succeeds regardless of database context.""" - result = execute_admin_command(collection, {"buildInfo": 1}) - assertSuccessPartial( - result, - {"ok": 1.0}, - msg="Should succeed regardless of database existence", - ) + """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/getCmdLineOpts/__init__.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/getCmdLineOpts/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/getCmdLineOpts/test_getCmdLineOpts_argument_handling.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/getCmdLineOpts/test_getCmdLineOpts_argument_handling.py new file mode 100644 index 000000000..cc4db7849 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/getCmdLineOpts/test_getCmdLineOpts_argument_handling.py @@ -0,0 +1,66 @@ +"""Tests for getCmdLineOpts command argument handling. + +Validates that getCmdLineOpts accepts any BSON type as its argument value. +""" + +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 ( + 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, BsonType + +pytestmark = pytest.mark.admin + + +BSON_TYPE_SPEC = BsonTypeTestCase( + id="getCmdLineOpts_arg", + msg="getCmdLineOpts should accept any BSON type as argument value", + keyword="getCmdLineOpts", + valid_types=list(BsonType), +) + +ACCEPTANCE_CASES = generate_bson_acceptance_test_cases([BSON_TYPE_SPEC]) + +EDGE_CASES: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "int_0", + command={"getCmdLineOpts": 0}, + checks={"ok": Eq(1.0)}, + msg="Should accept int 0", + ), + DiagnosticTestCase( + "int_neg1", + command={"getCmdLineOpts": -1}, + checks={"ok": Eq(1.0)}, + msg="Should accept int -1", + ), + DiagnosticTestCase( + "infinity", + command={"getCmdLineOpts": FLOAT_INFINITY}, + checks={"ok": Eq(1.0)}, + msg="Should accept infinity", + ), +] + + +@pytest.mark.parametrize("bson_type,sample_value,spec", ACCEPTANCE_CASES) +def test_getCmdLineOpts_argument_types(collection, bson_type, sample_value, spec): + """Test that getCmdLineOpts accepts various BSON types as argument value.""" + result = execute_admin_command(collection, {spec.keyword: sample_value}) + assertProperties(result, {"ok": Eq(1.0)}, msg=spec.msg, raw_res=True) + + +@pytest.mark.parametrize("test", pytest_params(EDGE_CASES)) +def test_getCmdLineOpts_argument_edge_cases(collection, test): + """Test that getCmdLineOpts accepts numeric edge case 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/diagnostic/commands/getCmdLineOpts/test_getCmdLineOpts_consistency.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/getCmdLineOpts/test_getCmdLineOpts_consistency.py new file mode 100644 index 000000000..7da27970c --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/getCmdLineOpts/test_getCmdLineOpts_consistency.py @@ -0,0 +1,35 @@ +"""Tests for getCmdLineOpts command output stability. + +Validates that getCmdLineOpts returns consistent results across repeated calls. +""" + +import pytest + +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.executor import execute_admin_command + +pytestmark = pytest.mark.admin + + +def test_getCmdLineOpts_parsed_stable(collection): + """Test the 'parsed' content is identical across consecutive calls.""" + result1 = execute_admin_command(collection, {"getCmdLineOpts": 1}) + result2 = execute_admin_command(collection, {"getCmdLineOpts": 1}) + assertSuccess( + result2["parsed"], + expected=result1["parsed"], + msg="'parsed' content should be identical across calls", + raw_res=True, + ) + + +def test_getCmdLineOpts_argv_stable(collection): + """Test the 'argv' content is identical across consecutive calls.""" + result1 = execute_admin_command(collection, {"getCmdLineOpts": 1}) + result2 = execute_admin_command(collection, {"getCmdLineOpts": 1}) + assertSuccess( + result2["argv"], + expected=result1["argv"], + msg="'argv' content should be identical across calls", + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/getCmdLineOpts/test_getCmdLineOpts_error_conditions.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/getCmdLineOpts/test_getCmdLineOpts_error_conditions.py new file mode 100644 index 000000000..2c0fed6af --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/getCmdLineOpts/test_getCmdLineOpts_error_conditions.py @@ -0,0 +1,63 @@ +"""Tests for getCmdLineOpts command error conditions. + +Validates that invalid usages of getCmdLineOpts 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, + UNAUTHORIZED_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="non_admin_database", + command={"getCmdLineOpts": 1}, + use_admin=False, + error_code=UNAUTHORIZED_ERROR, + msg="getCmdLineOpts may only be run against the admin database", + ), + DiagnosticTestCase( + id="unrecognized_field", + command={"getCmdLineOpts": 1, "unknownField": 1}, + use_admin=True, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="Should reject unrecognized fields", + ), + DiagnosticTestCase( + id="case_sensitive", + command={"GetCmdLineOpts": 1}, + use_admin=True, + error_code=COMMAND_NOT_FOUND_ERROR, + msg="Case-mismatched command name should fail", + ), + DiagnosticTestCase( + id="as_aggregation_stage", + command={"aggregate": "test", "pipeline": [{"$getCmdLineOpts": {}}], "cursor": {}}, + use_admin=False, + error_code=UNKNOWN_PIPELINE_STAGE_ERROR, + msg="$getCmdLineOpts is not a valid aggregation stage", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(ERROR_TESTS)) +def test_getCmdLineOpts_error_conditions(collection, test): + """Verifies getCmdLineOpts rejects invalid usages with appropriate error codes.""" + if test.use_admin: + result = execute_admin_command(collection, test.command) + else: + result = execute_command(collection, test.command) + assertFailureCode(result, test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/getCmdLineOpts/test_getCmdLineOpts_response_structure.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/getCmdLineOpts/test_getCmdLineOpts_response_structure.py new file mode 100644 index 000000000..2bf73dd6a --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/getCmdLineOpts/test_getCmdLineOpts_response_structure.py @@ -0,0 +1,47 @@ +"""Tests for getCmdLineOpts command response structure. + +Validates presence, types, and values of response fields. +""" + +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 + + +PROPERTY_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + id="ok_is_1", + checks={"ok": Eq(1.0)}, + msg="'ok' field should be 1.0", + ), + DiagnosticTestCase( + id="argv_is_array", + checks={"argv": IsType("array")}, + msg="'argv' field should be an array", + ), + DiagnosticTestCase( + id="argv_first_is_string", + checks={"argv.0": IsType("string")}, + msg="'argv' first element (binary path) should be a string", + ), + DiagnosticTestCase( + id="parsed_is_object", + checks={"parsed": IsType("object")}, + msg="'parsed' field should be a document", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(PROPERTY_TESTS)) +def test_getCmdLineOpts_response_properties(collection, test): + """Verifies getCmdLineOpts response fields have expected types and values.""" + result = execute_admin_command(collection, {"getCmdLineOpts": 1}) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/getLog/__init__.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/getLog/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/getLog/test_getLog_argument_validation.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/getLog/test_getLog_argument_validation.py new file mode 100644 index 000000000..d5b960f4a --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/getLog/test_getLog_argument_validation.py @@ -0,0 +1,41 @@ +"""Tests for getLog command argument validation. + +Covers BSON type handling for the ``getLog`` field value. Only a string is +accepted; every non-string type is rejected with TypeMismatch. + +Invalid string values (e.g. unknown components, the deprecated "rs") and +unrecognized command fields are covered in test_getLog_errors.py. +""" + +import pytest + +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.bson_type_validator import ( + 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.test_constants import BsonType + +pytestmark = pytest.mark.admin + +BSON_TYPE_PARAMS = [ + BsonTypeTestCase( + id="getLog_value", + msg="getLog should reject non-string value types", + keyword="getLog", + valid_types=[BsonType.STRING], + default_error_code=TYPE_MISMATCH_ERROR, + error_code_overrides={BsonType.NULL: MISSING_FIELD_ERROR}, + ), +] + +REJECTION_CASES = generate_bson_rejection_test_cases(BSON_TYPE_PARAMS) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", REJECTION_CASES) +def test_getLog_rejects_non_string_value(collection, bson_type, sample_value, spec): + """Test getLog rejects each non-string BSON type for its value.""" + result = execute_admin_command(collection, {"getLog": sample_value}) + assertFailureCode(result, spec.expected_code(bson_type), msg=spec.msg) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/getLog/test_getLog_errors.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/getLog/test_getLog_errors.py new file mode 100644 index 000000000..ebf03c413 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/getLog/test_getLog_errors.py @@ -0,0 +1,77 @@ +"""Tests for getLog command error conditions. + +Covers invalid log component names (unknown component, the deprecated "rs" +value, empty string), unrecognized command fields, and the admin-database +requirement. + +BSON type rejection/acceptance for the value is covered in +test_getLog_argument_validation.py. +""" + +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, + OPERATION_FAILED_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 + +pytestmark = pytest.mark.admin + + +ERROR_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "unknown_component", + command={"getLog": "invalid"}, + error_code=OPERATION_FAILED_ERROR, + msg="Unknown log component name should error", + ), + DiagnosticTestCase( + "deprecated_rs", + command={"getLog": "rs"}, + error_code=OPERATION_FAILED_ERROR, + msg="Deprecated 'rs' value should error", + ), + DiagnosticTestCase( + "empty_string", + command={"getLog": ""}, + error_code=OPERATION_FAILED_ERROR, + msg="Empty string component should error", + ), + DiagnosticTestCase( + "unrecognized_field", + command={"getLog": "global", "unknownField": 1}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="Unrecognized command field should error", + ), + DiagnosticTestCase( + "non_admin_database", + command={"getLog": "global"}, + use_admin=False, + error_code=UNAUTHORIZED_ERROR, + msg="getLog should only run on the admin database", + ), + DiagnosticTestCase( + "case_sensitive_command_name", + command={"GetLog": "global"}, + error_code=COMMAND_NOT_FOUND_ERROR, + msg="Command name is case-sensitive; 'GetLog' should not be found", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(ERROR_TESTS)) +def test_getLog_error(collection, test): + """Test getLog returns the expected error code for invalid arguments.""" + if test.use_admin: + result = execute_admin_command(collection, test.command) + else: + result = execute_command(collection, test.command) + assertFailureCode(result, test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/getLog/test_getLog_response_structure.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/getLog/test_getLog_response_structure.py new file mode 100644 index 000000000..2ddc57cea --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/getLog/test_getLog_response_structure.py @@ -0,0 +1,104 @@ +"""Tests for getLog command response structure. + +Covers response fields for the "global" filter (totalLinesWritten, log array +capped at 1024 entries, string log entries, ok), the "startupWarnings" filter +(totalLinesWritten, log array, ok), and the "*" filter (names array, ok). +Each test asserts a single response property. +""" + +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 ContainsElement, Eq, Gte, IsType, LenLte + +pytestmark = pytest.mark.admin + +MAX_LOG_EVENTS = 1024 + + +RESPONSE_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "global_totalLinesWritten_number", + command={"getLog": "global"}, + checks={"totalLinesWritten": Gte(0)}, + msg="global should return a non-negative totalLinesWritten", + ), + DiagnosticTestCase( + "global_log_is_array", + command={"getLog": "global"}, + checks={"log": IsType("array")}, + msg="global should return a log array", + ), + DiagnosticTestCase( + "global_log_capped_at_1024", + command={"getLog": "global"}, + checks={"log": LenLte(MAX_LOG_EVENTS)}, + msg="global log array should contain at most 1024 entries", + ), + DiagnosticTestCase( + "global_log_entry_is_string", + command={"getLog": "global"}, + checks={"log.0": IsType("string")}, + msg="global log entries should be JSON-formatted strings", + ), + DiagnosticTestCase( + "global_ok", + command={"getLog": "global"}, + checks={"ok": Eq(1.0)}, + msg="global should return ok:1", + ), + DiagnosticTestCase( + "startupWarnings_log_is_array", + command={"getLog": "startupWarnings"}, + checks={"log": IsType("array")}, + msg="startupWarnings should return a log array", + ), + DiagnosticTestCase( + "startupWarnings_ok", + command={"getLog": "startupWarnings"}, + checks={"ok": Eq(1.0)}, + msg="startupWarnings should return ok:1", + ), + DiagnosticTestCase( + "startupWarnings_totalLinesWritten_number", + command={"getLog": "startupWarnings"}, + checks={"totalLinesWritten": Gte(0)}, + msg="startupWarnings should return a non-negative totalLinesWritten", + ), + DiagnosticTestCase( + "wildcard_names_is_array", + command={"getLog": "*"}, + checks={"names": IsType("array")}, + msg="'*' should return a names array", + ), + DiagnosticTestCase( + "wildcard_names_contains_global", + command={"getLog": "*"}, + checks={"names": ContainsElement("global")}, + msg="'*' names should include 'global'", + ), + DiagnosticTestCase( + "wildcard_names_contains_startupWarnings", + command={"getLog": "*"}, + checks={"names": ContainsElement("startupWarnings")}, + msg="'*' names should include 'startupWarnings'", + ), + DiagnosticTestCase( + "wildcard_ok", + command={"getLog": "*"}, + checks={"ok": Eq(1.0)}, + msg="'*' should return ok:1", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(RESPONSE_TESTS)) +def test_getLog_response_properties(collection, test): + """Verify a getLog response field exists and has the expected type or 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/hostInfo/__init__.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/hostInfo/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/hostInfo/test_hostInfo_argument_handling.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/hostInfo/test_hostInfo_argument_handling.py new file mode 100644 index 000000000..d75e3df49 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/hostInfo/test_hostInfo_argument_handling.py @@ -0,0 +1,37 @@ +"""Tests for hostInfo command argument handling. + +hostInfo takes no arguments: the command value is ignored and any BSON type +is accepted, always returning ok:1. +""" + +import pytest + +from documentdb_tests.framework.assertions import assertProperties +from documentdb_tests.framework.bson_type_validator import ( + BsonTypeTestCase, + generate_bson_acceptance_test_cases, +) +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.property_checks import Eq +from documentdb_tests.framework.test_constants import BsonType + +pytestmark = pytest.mark.admin + + +BSON_TYPE_PARAMS = [ + BsonTypeTestCase( + id="hostInfo_command_value", + msg="hostInfo should accept any BSON type as command value", + keyword="hostInfo", + valid_types=list(BsonType), + ), +] + +ACCEPTANCE_CASES = generate_bson_acceptance_test_cases(BSON_TYPE_PARAMS) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", ACCEPTANCE_CASES) +def test_hostInfo_argument_types(collection, bson_type, sample_value, spec): + """Test that hostInfo accepts any BSON type as its command value.""" + result = execute_admin_command(collection, {"hostInfo": sample_value}) + assertProperties(result, {"ok": Eq(1.0)}, msg=spec.msg, raw_res=True) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/hostInfo/test_hostInfo_consistency.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/hostInfo/test_hostInfo_consistency.py new file mode 100644 index 000000000..41ae6868e --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/hostInfo/test_hostInfo_consistency.py @@ -0,0 +1,51 @@ +"""Tests for hostInfo core behavior and consistency. + +Validates that hostInfo ignores its argument value and succeeds across +different database contexts. +""" + +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 + + +ACCESS_TESTS = [ + # The MongoDB manual states hostInfo must be run against the admin database, + # but in practice it succeeds on any database. + DiagnosticTestCase( + id="succeeds_on_non_admin_db", + use_admin=False, + checks={"ok": Eq(1.0)}, + msg="hostInfo should succeed on a non-admin database", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(ACCESS_TESTS)) +def test_hostInfo_access(collection, test): + """Verify hostInfo access behaviour across different database contexts.""" + if test.use_admin: + result = execute_admin_command(collection, {"hostInfo": 1}) + else: + result = execute_command(collection, {"hostInfo": 1}) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) + + +def test_hostInfo_argument_value_ignored(collection): + """Verify the command value does not affect the os/extra output.""" + numeric = execute_admin_command(collection, {"hostInfo": 1}) + other = execute_admin_command(collection, {"hostInfo": "ignored"}) + assertProperties( + other, + {"os": Eq(numeric.get("os")), "extra": Eq(numeric.get("extra"))}, + raw_res=True, + msg="hostInfo output should not depend on the command value", + ) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/hostInfo/test_hostInfo_error_conditions.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/hostInfo/test_hostInfo_error_conditions.py new file mode 100644 index 000000000..c1d4e2857 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/hostInfo/test_hostInfo_error_conditions.py @@ -0,0 +1,47 @@ +"""Tests for hostInfo command error conditions. + +Validates that invalid usages of hostInfo produce the appropriate error codes. +""" + +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, + 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={"hostInfo": 1, "unknownField": 1}, + use_admin=True, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="Should reject unrecognized fields", + ), + DiagnosticTestCase( + id="case_sensitive", + command={"HostInfo": 1}, + use_admin=True, + error_code=COMMAND_NOT_FOUND_ERROR, + msg="Case-mismatched command name should fail", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(ERROR_TESTS)) +def test_hostInfo_error_conditions(collection, test): + """Verifies hostInfo rejects invalid usages with appropriate error codes.""" + if test.use_admin: + result = execute_admin_command(collection, test.command) + else: + result = execute_command(collection, test.command) + assertFailureCode(result, test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/hostInfo/test_hostInfo_response_structure.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/hostInfo/test_hostInfo_response_structure.py new file mode 100644 index 000000000..a6abd273c --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/hostInfo/test_hostInfo_response_structure.py @@ -0,0 +1,171 @@ +"""Tests for hostInfo command response structure. + +Validates presence, types, and value constraints of the system, os, and extra +sub-documents returned by hostInfo. +""" + +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 ( + Gt, + Gte, + IsType, + NonEmptyStr, +) + +pytestmark = pytest.mark.admin + + +PROPERTY_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + id="system_is_object", + checks={"system": IsType("object")}, + msg="'system' should be an embedded document", + ), + DiagnosticTestCase( + id="os_is_object", + checks={"os": IsType("object")}, + msg="'os' should be an embedded document", + ), + DiagnosticTestCase( + id="extra_is_object", + checks={"extra": IsType("object")}, + msg="'extra' should be an embedded document", + ), + DiagnosticTestCase( + id="system_currentTime_is_date", + checks={"system.currentTime": IsType("date")}, + msg="'system.currentTime' should be a date", + ), + DiagnosticTestCase( + id="system_hostname_nonempty", + checks={"system.hostname": NonEmptyStr()}, + msg="'system.hostname' should be a non-empty string", + ), + DiagnosticTestCase( + id="system_cpuAddrSize_positive", + checks={"system.cpuAddrSize": Gt(0)}, + msg="'system.cpuAddrSize' should be greater than 0", + ), + DiagnosticTestCase( + id="system_memLimitMB_positive", + checks={"system.memLimitMB": Gt(0)}, + msg="'system.memLimitMB' should be greater than 0", + ), + DiagnosticTestCase( + id="system_cpuArch_nonempty", + checks={"system.cpuArch": NonEmptyStr()}, + msg="'system.cpuArch' should be a non-empty string", + ), + DiagnosticTestCase( + id="system_numaEnabled_is_bool", + checks={"system.numaEnabled": IsType("bool")}, + msg="'system.numaEnabled' should be a bool", + ), + # The following three fields (numPhysicalCores, numCpuSockets, numNumaNodes) are + # observed in DocumentDB responses but are NOT listed in the MongoDB manual's + # hostInfo reference. The manual only documents numCores and + # numCoresAvailableToProcess under the system sub-document. + DiagnosticTestCase( + id="system_numPhysicalCores_positive", + checks={"system.numPhysicalCores": Gt(0)}, + msg="'system.numPhysicalCores' should be greater than 0", + ), + DiagnosticTestCase( + id="system_numCpuSockets_positive", + checks={"system.numCpuSockets": Gt(0)}, + msg="'system.numCpuSockets' should be greater than 0", + ), + DiagnosticTestCase( + id="system_numNumaNodes_positive", + checks={"system.numNumaNodes": Gt(0)}, + msg="'system.numNumaNodes' should be greater than 0", + ), + DiagnosticTestCase( + id="os_type_nonempty", + checks={"os.type": NonEmptyStr()}, + msg="'os.type' should be a non-empty string", + ), + DiagnosticTestCase( + id="os_name_is_string", + checks={"os.name": IsType("string")}, + msg="'os.name' should be a string", + ), + DiagnosticTestCase( + id="os_version_is_string", + checks={"os.version": IsType("string")}, + msg="'os.version' should be a string", + ), + DiagnosticTestCase( + id="extra_versionString_is_string", + checks={"extra.versionString": IsType("string")}, + msg="'extra.versionString' should be a string", + ), + DiagnosticTestCase( + id="extra_pageSize_positive", + checks={"extra.pageSize": Gt(0)}, + msg="'extra.pageSize' should be greater than 0", + ), + DiagnosticTestCase( + id="system_numCores_positive", + checks={"system.numCores": Gte(1)}, + msg="'system.numCores' should be at least 1", + ), + DiagnosticTestCase( + id="system_numCoresAvailableToProcess_gte_neg1", + checks={"system.numCoresAvailableToProcess": Gte(-1)}, + msg="'system.numCoresAvailableToProcess' should be >= -1", + ), + DiagnosticTestCase( + id="system_memSizeMB_positive", + checks={"system.memSizeMB": Gt(0)}, + msg="'system.memSizeMB' should be greater than 0", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(PROPERTY_TESTS)) +def test_hostInfo_response_properties(collection, test): + """Verifies hostInfo response fields have expected types and values.""" + result = execute_admin_command(collection, {"hostInfo": 1}) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) + + +def test_hostInfo_memSizeMB_gte_memLimitMB(collection): + """Verify system.memLimitMB does not exceed system.memSizeMB.""" + result = execute_admin_command(collection, {"hostInfo": 1}) + mem_limit = result.get("system", {}).get("memLimitMB") + assertProperties( + result, + {"system.memSizeMB": Gte(mem_limit)}, + raw_res=True, + msg="'system.memSizeMB' should be >= 'system.memLimitMB'", + ) + + +def test_hostInfo_extra_platform_specific_fields(collection): + """Verify extra contains the OS-specific fields documented for the host platform.""" + result = execute_admin_command(collection, {"hostInfo": 1}) + os_type = result.get("os", {}).get("type") + if os_type == "Linux": + checks = { + "extra.libcVersion": IsType("string"), + "extra.kernelVersion": IsType("string"), + "extra.numPages": Gt(0), + "extra.maxOpenFiles": Gt(0), + } + elif os_type == "Darwin": + checks = { + "extra.cpuString": IsType("string"), + } + else: + pytest.skip(f"Unrecognized os.type {os_type!r}; platform-specific fields not asserted") + assertProperties( + result, checks, raw_res=True, msg=f"extra should match documented {os_type} fields" + ) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/listCommands/__init__.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/listCommands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/listCommands/test_listCommands_argument_handling.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/listCommands/test_listCommands_argument_handling.py new file mode 100644 index 000000000..6be29a5c9 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/listCommands/test_listCommands_argument_handling.py @@ -0,0 +1,64 @@ +"""Tests for listCommands command argument handling. + +Validates that listCommands accepts any BSON type as its argument value +and rejects unrecognized fields. +""" + +import pytest + +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.property_checks import Eq + +pytestmark = pytest.mark.admin + + +# listCommands accepts all BSON types as its argument value +LISTCOMMANDS_BSON_TYPE_PARAMS = [ + BsonTypeTestCase( + id="argument", + msg="listCommands should accept any BSON type as argument", + keyword="listCommands", + valid_types=[ + BsonType.DOUBLE, + BsonType.STRING, + BsonType.OBJECT, + BsonType.ARRAY, + BsonType.BIN_DATA, + BsonType.OBJECT_ID, + BsonType.BOOL, + BsonType.DATE, + BsonType.NULL, + BsonType.REGEX, + BsonType.JAVASCRIPT, + BsonType.INT, + BsonType.TIMESTAMP, + BsonType.LONG, + BsonType.DECIMAL, + BsonType.MIN_KEY, + BsonType.MAX_KEY, + ], + ), +] + +ACCEPTANCE_TESTS = generate_bson_acceptance_test_cases(LISTCOMMANDS_BSON_TYPE_PARAMS) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", ACCEPTANCE_TESTS) +def test_listCommands_argument_types(collection, bson_type, sample_value, spec): + """Test that listCommands accepts any BSON type as argument value.""" + result = execute_admin_command(collection, {"listCommands": sample_value}) + assertProperties(result, {"ok": Eq(1.0)}, msg=f"{spec.msg} - {bson_type.value}", raw_res=True) + + +def test_listCommands_extra_fields_ignored(collection): + """Test that listCommands ignores extra unrecognized fields.""" + result = execute_admin_command(collection, {"listCommands": 1, "unknownField": 1}) + assertProperties( + result, {"ok": Eq(1.0)}, msg="Should succeed even with extra fields", raw_res=True + ) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/listCommands/test_listCommands_consistency.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/listCommands/test_listCommands_consistency.py new file mode 100644 index 000000000..1847d0a47 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/listCommands/test_listCommands_consistency.py @@ -0,0 +1,75 @@ +"""Tests for listCommands command consistency and availability. + +Validates that listCommands works across databases and returns consistent results. +""" + +import pytest + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, +) +from documentdb_tests.framework.assertions import ( + assertProperties, + assertSuccess, + 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 + +pytestmark = pytest.mark.admin + + +DATABASE_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + id="admin_database", + command={"listCommands": 1}, + use_admin=True, + checks={"ok": Eq(1.0)}, + msg="Should succeed on admin database", + ), + DiagnosticTestCase( + id="non_admin_database", + command={"listCommands": 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_listCommands_database_availability(collection, test): + """Test listCommands works 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_listCommands_nonexistent_database(collection): + """Test listCommands 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, {"listCommands": 1}) + assertSuccessPartial(result, {"ok": 1.0}, msg="Should succeed on non-existent database") + + +def test_listCommands_idempotent(collection): + """Test calling listCommands multiple times returns identical results.""" + result1 = execute_admin_command(collection, {"listCommands": 1}) + result2 = execute_admin_command(collection, {"listCommands": 1}) + assertSuccess(result2, expected=result1, msg="Should return identical results", raw_res=True) + + +def test_listCommands_same_result_any_database(collection): + """Test listCommands returns same result from admin and non-admin database.""" + admin_result = execute_admin_command(collection, {"listCommands": 1}) + db_result = execute_command(collection, {"listCommands": 1}) + assertSuccess( + db_result, + expected=admin_result, + msg="Should return same result from any database", + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/listCommands/test_listCommands_error_conditions.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/listCommands/test_listCommands_error_conditions.py new file mode 100644 index 000000000..e16cf625e --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/listCommands/test_listCommands_error_conditions.py @@ -0,0 +1,47 @@ +"""Tests for listCommands command error conditions. + +Validates that invalid usages of listCommands 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, +) +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="as_aggregation_stage", + command={"aggregate": "test", "pipeline": [{"$listCommands": {}}], "cursor": {}}, + use_admin=False, + error_code=UNKNOWN_PIPELINE_STAGE_ERROR, + msg="$listCommands is not a valid aggregation stage", + ), + DiagnosticTestCase( + id="case_sensitive", + command={"ListCommands": 1}, + use_admin=True, + error_code=COMMAND_NOT_FOUND_ERROR, + msg="Case-mismatched command name should fail", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(ERROR_TESTS)) +def test_listCommands_error_conditions(collection, test): + """Verifies listCommands rejects invalid usages with appropriate error codes.""" + if test.use_admin: + result = execute_admin_command(collection, test.command) + else: + result = execute_command(collection, test.command) + assertFailureCode(result, test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/listCommands/test_listCommands_response_structure.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/listCommands/test_listCommands_response_structure.py new file mode 100644 index 000000000..0813e84fa --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/listCommands/test_listCommands_response_structure.py @@ -0,0 +1,124 @@ +"""Tests for listCommands command response structure. + +Validates the response format, field types, and presence of expected commands. +""" + +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, Exists, IsType + +pytestmark = pytest.mark.admin + + +RESPONSE_FIELD_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + id="ok_is_1", + checks={"ok": Eq(1.0)}, + msg="'ok' field should be 1.0", + ), + DiagnosticTestCase( + id="commands_is_object", + checks={"commands": IsType("object")}, + msg="'commands' field should be an object", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(RESPONSE_FIELD_TESTS)) +def test_listCommands_response_fields(collection, test): + """Verify listCommands response contains expected fields with correct types.""" + result = execute_admin_command(collection, {"listCommands": 1}) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) + + +COMMAND_ENTRY_STRUCTURE_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + id="find_has_help", + checks={"commands.find.help": IsType("string")}, + msg="Command entry 'find' should have 'help' field of type string", + ), + DiagnosticTestCase( + id="find_has_adminOnly", + checks={"commands.find.adminOnly": IsType("bool")}, + msg="Command entry 'find' should have 'adminOnly' field of type boolean", + ), + DiagnosticTestCase( + id="find_has_requiresAuth", + checks={"commands.find.requiresAuth": IsType("bool")}, + msg="Command entry 'find' should have 'requiresAuth' field of type boolean", + ), + DiagnosticTestCase( + id="find_has_secondaryOk", + checks={"commands.find.secondaryOk": IsType("bool")}, + msg="Command entry 'find' should have 'secondaryOk' field of type boolean", + ), + DiagnosticTestCase( + id="find_has_apiVersions", + checks={"commands.find.apiVersions": IsType("array")}, + msg="Command entry 'find' should have 'apiVersions' field of type array", + ), + DiagnosticTestCase( + id="find_has_deprecatedApiVersions", + checks={"commands.find.deprecatedApiVersions": IsType("array")}, + msg="Command entry 'find' should have 'deprecatedApiVersions' field of type array", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(COMMAND_ENTRY_STRUCTURE_TESTS)) +def test_listCommands_command_entry_structure(collection, test): + """Verify each command entry contains expected fields with correct types.""" + result = execute_admin_command(collection, {"listCommands": 1}) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) + + +KNOWN_COMMANDS_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + id="find_present", + checks={"commands.find": Exists()}, + msg="'find' should be listed", + ), + DiagnosticTestCase( + id="insert_present", + checks={"commands.insert": Exists()}, + msg="'insert' should be listed", + ), + DiagnosticTestCase( + id="update_present", + checks={"commands.update": Exists()}, + msg="'update' should be listed", + ), + DiagnosticTestCase( + id="delete_present", + checks={"commands.delete": Exists()}, + msg="'delete' should be listed", + ), + DiagnosticTestCase( + id="aggregate_present", + checks={"commands.aggregate": Exists()}, + msg="'aggregate' should be listed", + ), + DiagnosticTestCase( + id="ping_present", + checks={"commands.ping": Exists()}, + msg="'ping' should be listed", + ), + DiagnosticTestCase( + id="listCommands_present", + checks={"commands.listCommands": Exists()}, + msg="'listCommands' should be listed in its own output", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(KNOWN_COMMANDS_TESTS)) +def test_listCommands_known_commands_present(collection, test): + """Verify well-known commands appear in the listCommands response.""" + result = execute_admin_command(collection, {"listCommands": 1}) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/lockInfo/__init__.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/lockInfo/__init__.py new file mode 100644 index 000000000..49a4f47a0 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/lockInfo/__init__.py @@ -0,0 +1 @@ +"""Tests for the lockInfo diagnostic command.""" diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/lockInfo/test_lockInfo_argument_validation.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/lockInfo/test_lockInfo_argument_validation.py new file mode 100644 index 000000000..585d7576b --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/lockInfo/test_lockInfo_argument_validation.py @@ -0,0 +1,78 @@ +"""Tests for lockInfo command argument validation. + +Verifies that lockInfo accepts any BSON type for the command field value and +that successive calls succeed (point-in-time snapshot behaviour). +""" + +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 + + +LOCKINFO_BSON_TYPE_SPECS = [ + BsonTypeTestCase( + id="command_field", + msg="lockInfo accepts any BSON type for the command field", + keyword="lockInfo", + valid_types=list(BsonType), + ) +] + +ACCEPTANCE_TESTS = generate_bson_acceptance_test_cases(LOCKINFO_BSON_TYPE_SPECS) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", ACCEPTANCE_TESTS) +def test_lockInfo_accepts_any_type(collection, bson_type, sample_value, spec): + """Verify lockInfo succeeds when the command field value is a given BSON type.""" + result = execute_admin_command(collection, {"lockInfo": sample_value}) + assertProperties(result, {"ok": Eq(1.0)}, msg=spec.msg, raw_res=True) + + +VALUE_EDGE_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + id="int_zero", + command={"lockInfo": 0}, + checks={"ok": Eq(1.0)}, + msg="lockInfo should accept int 0", + ), + DiagnosticTestCase( + id="int_neg1", + command={"lockInfo": -1}, + checks={"ok": Eq(1.0)}, + msg="lockInfo should accept int -1", + ), + DiagnosticTestCase( + id="float_infinity", + command={"lockInfo": FLOAT_INFINITY}, + checks={"ok": Eq(1.0)}, + msg="lockInfo should accept float infinity as the command field value", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(VALUE_EDGE_TESTS)) +def test_lockInfo_accepts_value_edge_cases(collection, test): + """Verify lockInfo accepts int and float edge values for the command field.""" + result = execute_admin_command(collection, test.command) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) + + +def test_lockInfo_point_in_time_snapshot(collection): + """Test lockInfo result is a point-in-time snapshot (successive calls succeed).""" + execute_admin_command(collection, {"lockInfo": 1}) + result = execute_admin_command(collection, {"lockInfo": 1}) + assertProperties(result, {"ok": Eq(1.0)}, msg="Successive calls should succeed", raw_res=True) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/lockInfo/test_lockInfo_errors.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/lockInfo/test_lockInfo_errors.py new file mode 100644 index 000000000..391c211d1 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/lockInfo/test_lockInfo_errors.py @@ -0,0 +1,56 @@ +"""Tests for lockInfo command error cases. + +Verifies that lockInfo returns correct error codes when run on non-admin +database or with unrecognized fields. +""" + +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, + 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 + +pytestmark = pytest.mark.admin + + +ERROR_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + id="non_admin_database", + command={"lockInfo": 1}, + use_admin=False, + error_code=UNAUTHORIZED_ERROR, + msg="lockInfo on non-admin db should be unauthorized", + ), + DiagnosticTestCase( + id="unrecognized_field", + command={"lockInfo": 1, "foo": 1}, + use_admin=True, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="Unrecognized field should error", + ), + DiagnosticTestCase( + id="wrong_case", + command={"LockInfo": 1}, + use_admin=True, + error_code=COMMAND_NOT_FOUND_ERROR, + msg="Command name is case-sensitive; 'LockInfo' should not be found", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(ERROR_TESTS)) +def test_lockInfo_error_conditions(collection, test): + """Verifies lockInfo returns appropriate error codes for invalid usages.""" + if test.use_admin: + result = execute_admin_command(collection, test.command) + else: + result = execute_command(collection, test.command) + assertFailureCode(result, test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/lockInfo/test_lockInfo_response_structure.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/lockInfo/test_lockInfo_response_structure.py new file mode 100644 index 000000000..42e27eabf --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/lockInfo/test_lockInfo_response_structure.py @@ -0,0 +1,64 @@ +"""Tests for lockInfo command response structure. + +Verifies the top-level structure of the lockInfo response: ok field and +lockInfo array field. Also verifies the structure of individual lock entries +using an fsync lock to guarantee a non-empty snapshot. +""" + +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, pytest.mark.no_parallel] + + +RESPONSE_STRUCTURE_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "ok_field", + checks={"ok": Eq(1.0)}, + msg="Response should contain ok: 1.0", + ), + DiagnosticTestCase( + "lockInfo_is_array", + checks={"lockInfo": IsType("array")}, + msg="lockInfo field should be an array", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(RESPONSE_STRUCTURE_TESTS)) +def test_lockInfo_response_structure(collection, test): + """Verify lockInfo response contains expected fields with correct types.""" + result = execute_admin_command(collection, {"lockInfo": 1}) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) + + +def test_lockInfo_entry_structure(collection): + """Test a lock entry exposes resourceId (string), granted (array), and pending (array). + + Acquires a global fsync lock so the lock manager reports at least one entry, + guaranteeing the assertion runs against a real lock entry rather than an + empty snapshot. Must not run in parallel — fsyncLock is a global server lock. + """ + execute_admin_command(collection, {"fsync": 1, "lock": True}) + try: + result = execute_admin_command(collection, {"lockInfo": 1}) + entry = result["lockInfo"][0] + finally: + execute_admin_command(collection, {"fsyncUnlock": 1}) + assertProperties( + entry, + { + "resourceId": IsType("string"), + "granted": IsType("array"), + "pending": IsType("array"), + }, + msg="lock entry should expose resourceId (string), granted (array), pending (array)", + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/top/__init__.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/top/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/top/test_top_argument_handling.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/top/test_top_argument_handling.py new file mode 100644 index 000000000..e40863497 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/top/test_top_argument_handling.py @@ -0,0 +1,157 @@ +"""Tests for top command argument handling. + +Validates that top accepts any BSON type as its argument value and +accepts unrecognized fields. +""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +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 + +pytestmark = pytest.mark.admin + +# Property [BSON Type Acceptance]: top accepts any non-deprecated BSON type as command value. +ARGUMENT_TYPE_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "int_1", command={"top": 1}, checks={"ok": Eq(1.0)}, msg="Should accept int 1" + ), + DiagnosticTestCase( + "int_0", command={"top": 0}, checks={"ok": Eq(1.0)}, msg="Should accept int 0" + ), + DiagnosticTestCase( + "int_neg1", command={"top": -1}, checks={"ok": Eq(1.0)}, msg="Should accept int -1" + ), + DiagnosticTestCase( + "bool_true", command={"top": True}, checks={"ok": Eq(1.0)}, msg="Should accept true" + ), + DiagnosticTestCase( + "bool_false", + command={"top": False}, + checks={"ok": Eq(1.0)}, + msg="Should accept false", + ), + DiagnosticTestCase( + "string", command={"top": "hello"}, checks={"ok": Eq(1.0)}, msg="Should accept string" + ), + DiagnosticTestCase( + "null", command={"top": None}, checks={"ok": Eq(1.0)}, msg="Should accept null" + ), + DiagnosticTestCase( + "empty_object", + command={"top": {}}, + checks={"ok": Eq(1.0)}, + msg="Should accept empty object", + ), + DiagnosticTestCase( + "empty_array", + command={"top": []}, + checks={"ok": Eq(1.0)}, + msg="Should accept empty array", + ), + DiagnosticTestCase( + "double", command={"top": 1.5}, checks={"ok": Eq(1.0)}, msg="Should accept double" + ), + DiagnosticTestCase( + "int64", command={"top": Int64(1)}, checks={"ok": Eq(1.0)}, msg="Should accept int64" + ), + DiagnosticTestCase( + "decimal128", + command={"top": Decimal128("1")}, + checks={"ok": Eq(1.0)}, + msg="Should accept decimal128", + ), + DiagnosticTestCase( + "decimal128_nan", + command={"top": Decimal128("NaN")}, + checks={"ok": Eq(1.0)}, + msg="Should accept decimal128 NaN", + ), + DiagnosticTestCase( + "infinity", + command={"top": float("inf")}, + checks={"ok": Eq(1.0)}, + msg="Should accept infinity", + ), + DiagnosticTestCase( + "date", + command={"top": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + checks={"ok": Eq(1.0)}, + msg="Should accept date", + ), + DiagnosticTestCase( + "binData", + command={"top": Binary(b"")}, + checks={"ok": Eq(1.0)}, + msg="Should accept binData", + ), + DiagnosticTestCase( + "objectId", + command={"top": ObjectId()}, + checks={"ok": Eq(1.0)}, + msg="Should accept objectId", + ), + DiagnosticTestCase( + "regex", + command={"top": Regex("test")}, + checks={"ok": Eq(1.0)}, + msg="Should accept regex", + ), + DiagnosticTestCase( + "timestamp", + command={"top": Timestamp(0, 0)}, + checks={"ok": Eq(1.0)}, + msg="Should accept timestamp", + ), + DiagnosticTestCase( + "minKey", + command={"top": MinKey()}, + checks={"ok": Eq(1.0)}, + msg="Should accept minKey", + ), + DiagnosticTestCase( + "maxKey", + command={"top": MaxKey()}, + checks={"ok": Eq(1.0)}, + msg="Should accept maxKey", + ), + DiagnosticTestCase( + "code", + command={"top": Code("function(){}")}, + checks={"ok": Eq(1.0)}, + msg="Should accept JavaScript code", + ), +] + +# Property [Unrecognized Fields]: top accepts and ignores unrecognized fields. +UNRECOGNIZED_FIELD_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "single_unrecognized_field", + command={"top": 1, "unknownField": 1}, + checks={"ok": Eq(1.0)}, + msg="top should accept a single unrecognized field", + ), + DiagnosticTestCase( + "multiple_unrecognized_fields", + command={"top": 1, "foo": 1, "bar": "baz", "qux": []}, + checks={"ok": Eq(1.0)}, + msg="top should accept multiple unrecognized fields", + ), +] + +ARGUMENT_HANDLING_TESTS = ARGUMENT_TYPE_TESTS + UNRECOGNIZED_FIELD_TESTS + + +@pytest.mark.parametrize("test", pytest_params(ARGUMENT_HANDLING_TESTS)) +def test_top_argument_handling(collection, test): + """Test that top accepts various BSON types and unrecognized fields.""" + 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/top/test_top_consistency.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/top/test_top_consistency.py new file mode 100644 index 000000000..aed395b17 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/top/test_top_consistency.py @@ -0,0 +1,178 @@ +"""Tests for top command consistency, visibility, and special collection types. + +Validates idempotency, namespace visibility, system namespace structure, +and behavior with capped collections and views. + +Standalone functions are used throughout because every test requires runtime +logic that DiagnosticTestCase cannot express: multi-call comparisons with +dynamic thresholds, namespace key extraction from the response, conditional +skips, or ad-hoc collection creation (capped, views). +""" + +import pytest + +from documentdb_tests.framework.assertions import assertProperties, assertSuccessPartial +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.property_checks import Gte, IsType + +pytestmark = pytest.mark.admin + + +# Property [Idempotency]: repeated top calls succeed and counters are non-decreasing. + + +def test_top_repeated_calls_return_ok(collection): + """Test that calling top returns ok after multiple calls.""" + for _ in range(5): + execute_admin_command(collection, {"top": 1}) + result = execute_admin_command(collection, {"top": 1}) + assertSuccessPartial(result, {"ok": 1.0}, msg="top should succeed after repeated calls") + + +def test_top_counters_non_decreasing_count(collection): + """Test that total.count is non-decreasing across two consecutive calls.""" + collection.insert_one({"_id": 1}) + ns = f"{collection.database.name}.{collection.name}" + result1 = execute_admin_command(collection, {"top": 1}) + ns_data1 = result1["totals"].get(ns) + if ns_data1 is None: + raise AssertionError(f"Namespace {ns} not found in first top call") + count1 = ns_data1["total"]["count"] + result2 = execute_admin_command(collection, {"top": 1}) + ns_data2 = result2["totals"].get(ns) + if ns_data2 is None: + raise AssertionError(f"Namespace {ns} not found in second top call") + assertProperties( + ns_data2, + {"total.count": Gte(count1)}, + msg="total.count should be non-decreasing", + raw_res=True, + ) + + +def test_top_counters_non_decreasing_time(collection): + """Test that total.time is non-decreasing across two consecutive calls.""" + collection.insert_one({"_id": 1}) + ns = f"{collection.database.name}.{collection.name}" + result1 = execute_admin_command(collection, {"top": 1}) + ns_data1 = result1["totals"].get(ns) + if ns_data1 is None: + raise AssertionError(f"Namespace {ns} not found in first top call") + time1 = ns_data1["total"]["time"] + result2 = execute_admin_command(collection, {"top": 1}) + ns_data2 = result2["totals"].get(ns) + if ns_data2 is None: + raise AssertionError(f"Namespace {ns} not found in second top call") + assertProperties( + ns_data2, + {"total.time": Gte(time1)}, + msg="total.time should be non-decreasing", + raw_res=True, + ) + + +# Property [Collection Visibility]: active collections appear in totals as db.collection keys. + + +def test_top_newly_created_collection_appears(collection): + """Test that a newly created collection appears in top totals.""" + collection.insert_one({"_id": 1}) + result = execute_admin_command(collection, {"top": 1}) + ns = f"{collection.database.name}.{collection.name}" + ns_data = result["totals"].get(ns) + if ns_data is None: + raise AssertionError(f"Namespace {ns} not found in top totals") + assertProperties( + ns_data, + {"total": IsType("object")}, + msg=f"Namespace {ns} should appear in top totals", + raw_res=True, + ) + + +def test_top_multiple_collections_appear(collection): + """Test that multiple collections appear in top totals.""" + db = collection.database + coll1 = db.create_collection(f"{collection.name}_multi1") + coll2 = db.create_collection(f"{collection.name}_multi2") + coll1.insert_one({"_id": 1}) + coll2.insert_one({"_id": 1}) + result = execute_admin_command(coll1, {"top": 1}) + ns1 = f"{db.name}.{coll1.name}" + ns2 = f"{db.name}.{coll2.name}" + ns1_data = result["totals"].get(ns1) + ns2_data = result["totals"].get(ns2) + if ns1_data is None: + raise AssertionError(f"Namespace {ns1} not found in top totals") + if ns2_data is None: + raise AssertionError(f"Namespace {ns2} not found in top totals") + assertProperties( + {"ns1": ns1_data, "ns2": ns2_data}, + {"ns1": IsType("object"), "ns2": IsType("object")}, + msg="Both namespaces should appear in top totals", + raw_res=True, + ) + + +# Property [System Collections]: system namespaces have the standard event field structure. + + +def test_top_system_collections_have_event_structure(collection): + """Test that a system namespace in totals has the expected event field structure.""" + collection.insert_one({"_id": 1}) + result = execute_admin_command(collection, {"top": 1}) + system_ns = None + for ns_key in result["totals"]: + if ".system." in ns_key or ns_key.startswith("admin.") or ns_key.startswith("local."): + system_ns = ns_key + break + if system_ns is None: + pytest.skip("No system namespace found in top totals") + ns_data = result["totals"][system_ns] + assertProperties( + ns_data, + {"total": IsType("object"), "total.time": Gte(0), "total.count": Gte(0)}, + msg=f"System namespace {system_ns} should have event fields with time/count", + raw_res=True, + ) + + +# Property [Special Collection Types]: capped collections and views are handled by top. + + +def test_top_tracks_capped_collection(collection): + """Test that a capped collection appears in top totals with expected structure.""" + db = collection.database + coll = db.create_collection(f"{collection.name}_capped", capped=True, size=4096) + coll.insert_one({"_id": 1}) + result = execute_admin_command(coll, {"top": 1}) + ns = f"{db.name}.{coll.name}" + ns_data = result["totals"].get(ns) + if ns_data is None: + raise AssertionError(f"Namespace {ns} not found in top totals") + assertProperties( + ns_data, + {"total": IsType("object"), "total.time": Gte(0), "total.count": Gte(0)}, + msg="Capped collection should appear in top totals with expected structure", + raw_res=True, + ) + + +def test_top_tracks_view(collection): + """Test that a view namespace appears in top totals.""" + db = collection.database + source_coll = db.create_collection(f"{collection.name}_view_src") + source_coll.insert_one({"_id": 1}) + view_name = f"{collection.name}_view" + db.command("create", view_name, viewOn=source_coll.name, pipeline=[]) + result = execute_admin_command(source_coll, {"top": 1}) + view_ns = f"{db.name}.{view_name}" + view_data = result["totals"].get(view_ns) + if view_data is None: + raise AssertionError(f"Namespace {view_ns} not found in top totals") + assertProperties( + view_data, + {"total": IsType("object")}, + msg=f"View namespace {view_ns} should appear in top totals", + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/top/test_top_core_behavior.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/top/test_top_core_behavior.py new file mode 100644 index 000000000..2f1082f66 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/top/test_top_core_behavior.py @@ -0,0 +1,216 @@ +"""Tests for top command core behavior. + +Validates that counters reflect operations and cross-lock consistency invariants hold. +""" + +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 Gt, Gte + +pytestmark = pytest.mark.admin + + +# Property [Counter Behavior - Insert]: insert operations populate insert, writeLock, and time. +INSERT_COUNTER_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "insert_count_gte_1", + command={"top": 1}, + checks={"insert.count": Gte(1)}, + msg="insert.count should be >= 1 after inserts", + ), + DiagnosticTestCase( + "insert_writeLock_count_gte_1", + command={"top": 1}, + checks={"writeLock.count": Gte(1)}, + msg="writeLock.count should be >= 1 after inserts", + ), + DiagnosticTestCase( + "insert_time_gt_0", + command={"top": 1}, + checks={"insert.time": Gt(0)}, + msg="insert.time should be > 0 after inserts", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(INSERT_COUNTER_TESTS)) +def test_top_after_inserts(collection, test): + """Test counters after insert operations.""" + collection.insert_many([{"_id": i} for i in range(10)]) + result = execute_admin_command(collection, test.command) + ns = f"{collection.database.name}.{collection.name}" + ns_data = result["totals"].get(ns) + if ns_data is None: + raise AssertionError(f"Namespace {ns} not found in top totals") + assertProperties(ns_data, test.checks, msg=test.msg, raw_res=True) + + +# Property [Counter Behavior - Query]: find operations populate queries, readLock, and time. +QUERY_COUNTER_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "queries_count_gte_1", + command={"top": 1}, + checks={"queries.count": Gte(1)}, + msg="queries.count should be >= 1 after query", + ), + DiagnosticTestCase( + "query_readLock_count_gte_1", + command={"top": 1}, + checks={"readLock.count": Gte(1)}, + msg="readLock.count should be >= 1 after query", + ), + DiagnosticTestCase( + "queries_time_gt_0", + command={"top": 1}, + checks={"queries.time": Gt(0)}, + msg="queries.time should be > 0 after query", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(QUERY_COUNTER_TESTS)) +def test_top_after_query(collection, test): + """Test counters after find operations.""" + collection.insert_one({"_id": 1}) + list(collection.find()) + result = execute_admin_command(collection, test.command) + ns = f"{collection.database.name}.{collection.name}" + ns_data = result["totals"].get(ns) + if ns_data is None: + raise AssertionError(f"Namespace {ns} not found in top totals") + assertProperties(ns_data, test.checks, msg=test.msg, raw_res=True) + + +# Property [Counter Behavior - Update]: update operations populate the update counter. +UPDATE_COUNTER_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "update_count_gte_1", + command={"top": 1}, + checks={"update.count": Gte(1)}, + msg="update.count should be >= 1 after update", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(UPDATE_COUNTER_TESTS)) +def test_top_after_update(collection, test): + """Test counters after update operations.""" + collection.insert_one({"_id": 1, "a": 1}) + collection.update_one({"_id": 1}, {"$set": {"a": 2}}) + result = execute_admin_command(collection, test.command) + ns = f"{collection.database.name}.{collection.name}" + ns_data = result["totals"].get(ns) + if ns_data is None: + raise AssertionError(f"Namespace {ns} not found in top totals") + assertProperties(ns_data, test.checks, msg=test.msg, raw_res=True) + + +# Property [Counter Behavior - Remove]: delete operations populate the remove counter. +REMOVE_COUNTER_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "remove_count_gte_1", + command={"top": 1}, + checks={"remove.count": Gte(1)}, + msg="remove.count should be >= 1 after delete", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(REMOVE_COUNTER_TESTS)) +def test_top_after_remove(collection, test): + """Test counters after delete operations.""" + collection.insert_one({"_id": 1}) + collection.delete_one({"_id": 1}) + result = execute_admin_command(collection, test.command) + ns = f"{collection.database.name}.{collection.name}" + ns_data = result["totals"].get(ns) + if ns_data is None: + raise AssertionError(f"Namespace {ns} not found in top totals") + assertProperties(ns_data, test.checks, msg=test.msg, raw_res=True) + + +# Property [Cross-Lock Invariants]: aggregate lock counters are >= the sum of their components. + + +def test_top_readLock_count_gte_queries_count(collection): + """Test that readLock.count >= queries.count.""" + collection.insert_many([{"_id": i, "a": i} for i in range(5)]) + list(collection.find()) + collection.update_one({"_id": 0}, {"$set": {"a": 99}}) + collection.delete_one({"_id": 4}) + result = execute_admin_command(collection, {"top": 1}) + ns = f"{collection.database.name}.{collection.name}" + ns_data = result["totals"].get(ns) + if ns_data is None: + raise AssertionError(f"Namespace {ns} not found in top totals") + assertProperties( + ns_data, + {"readLock.count": Gte(ns_data["queries"]["count"])}, + msg="readLock.count should be >= queries.count", + raw_res=True, + ) + + +def test_top_readLock_time_gte_queries_time(collection): + """Test that readLock.time >= queries.time.""" + collection.insert_many([{"_id": i, "a": i} for i in range(5)]) + list(collection.find()) + collection.update_one({"_id": 0}, {"$set": {"a": 99}}) + collection.delete_one({"_id": 4}) + result = execute_admin_command(collection, {"top": 1}) + ns = f"{collection.database.name}.{collection.name}" + ns_data = result["totals"].get(ns) + if ns_data is None: + raise AssertionError(f"Namespace {ns} not found in top totals") + assertProperties( + ns_data, + {"readLock.time": Gte(ns_data["queries"]["time"])}, + msg="readLock.time should be >= queries.time", + raw_res=True, + ) + + +def test_top_writeLock_count_gte_insert_update_remove(collection): + """Test that writeLock.count >= insert.count + update.count + remove.count.""" + collection.insert_many([{"_id": i, "a": i} for i in range(5)]) + list(collection.find()) + collection.update_one({"_id": 0}, {"$set": {"a": 99}}) + collection.delete_one({"_id": 4}) + result = execute_admin_command(collection, {"top": 1}) + ns = f"{collection.database.name}.{collection.name}" + ns_data = result["totals"].get(ns) + if ns_data is None: + raise AssertionError(f"Namespace {ns} not found in top totals") + write_sum = ns_data["insert"]["count"] + ns_data["update"]["count"] + ns_data["remove"]["count"] + assertProperties( + ns_data, + {"writeLock.count": Gte(write_sum)}, + msg="writeLock.count should be >= insert+update+remove count", + raw_res=True, + ) + + +def test_top_writeLock_time_gte_insert_update_remove(collection): + """Test that writeLock.time >= insert.time + update.time + remove.time.""" + collection.insert_many([{"_id": i, "a": i} for i in range(5)]) + list(collection.find()) + collection.update_one({"_id": 0}, {"$set": {"a": 99}}) + collection.delete_one({"_id": 4}) + result = execute_admin_command(collection, {"top": 1}) + ns = f"{collection.database.name}.{collection.name}" + ns_data = result["totals"].get(ns) + if ns_data is None: + raise AssertionError(f"Namespace {ns} not found in top totals") + write_sum = ns_data["insert"]["time"] + ns_data["update"]["time"] + ns_data["remove"]["time"] + assertProperties( + ns_data, + {"writeLock.time": Gte(write_sum)}, + msg="writeLock.time should be >= insert+update+remove time", + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/top/test_top_errors.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/top/test_top_errors.py new file mode 100644 index 000000000..f7a2e50ba --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/top/test_top_errors.py @@ -0,0 +1,64 @@ +"""Tests for top command error conditions. + +Validates that invalid usages of top 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, 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 + +# Property [Case Sensitivity]: command names are case-sensitive. +CASE_SENSITIVITY_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + id="case_sensitive_Top", + command={"Top": 1}, + use_admin=True, + error_code=COMMAND_NOT_FOUND_ERROR, + msg="'Top' (capitalized) should not be recognized", + ), + DiagnosticTestCase( + id="case_sensitive_TOP", + command={"TOP": 1}, + use_admin=True, + error_code=COMMAND_NOT_FOUND_ERROR, + msg="'TOP' (all caps) should not be recognized", + ), + DiagnosticTestCase( + id="case_sensitive_tOP", + command={"tOP": 1}, + use_admin=True, + error_code=COMMAND_NOT_FOUND_ERROR, + msg="'tOP' (mixed case) should not be recognized", + ), +] + +# Property [Admin Database Required]: top fails on non-admin database. +ADMIN_DB_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + id="non_admin_db", + command={"top": 1}, + use_admin=False, + error_code=UNAUTHORIZED_ERROR, + msg="top should fail on non-admin db", + ), +] + +ERROR_TESTS = CASE_SENSITIVITY_TESTS + ADMIN_DB_TESTS + + +@pytest.mark.parametrize("test", pytest_params(ERROR_TESTS)) +def test_top_error_conditions(collection, test): + """Test that invalid top command usages produce appropriate errors.""" + if test.use_admin: + result = execute_admin_command(collection, test.command) + else: + result = execute_command(collection, test.command) + assertFailureCode(result, test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/top/test_top_response_structure.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/top/test_top_response_structure.py new file mode 100644 index 000000000..7389e7868 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/top/test_top_response_structure.py @@ -0,0 +1,116 @@ +"""Tests for top command response structure. + +Validates top-level response fields and per-collection event field structure. +""" + +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, Gte, IsType + +pytestmark = pytest.mark.admin + + +# Property [Top-Level Fields]: top response contains totals, totals.note, and ok fields. +TOP_LEVEL_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + id="response_has_totals", + checks={"totals": IsType("object")}, + msg="'totals' field should be an object", + ), + DiagnosticTestCase( + id="response_has_note", + checks={"totals.note": Eq("all times in microseconds")}, + msg="'totals.note' should describe time units", + ), + DiagnosticTestCase( + id="response_has_ok", + checks={"ok": Eq(1.0)}, + msg="'ok' field should be 1.0", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(TOP_LEVEL_TESTS)) +def test_top_response_top_level(collection, test): + """Test that top response contains expected top-level fields.""" + result = execute_admin_command(collection, {"top": 1}) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) + + +# Property [Per-Collection Event Fields]: each namespace entry has 9 event fields with time/count. +EVENT_EXISTS_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + id=f"event_{name}_exists", + checks={name: IsType("object")}, + msg=f"'{name}' event field should be an object", + ) + for name in [ + "total", + "readLock", + "writeLock", + "queries", + "getmore", + "insert", + "update", + "remove", + "commands", + ] +] + +EVENT_TIME_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + id=f"event_{name}_time_gte_0", + checks={f"{name}.time": Gte(0)}, + msg=f"'{name}.time' should be >= 0", + ) + for name in [ + "total", + "readLock", + "writeLock", + "queries", + "getmore", + "insert", + "update", + "remove", + "commands", + ] +] + +EVENT_COUNT_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + id=f"event_{name}_count_gte_0", + checks={f"{name}.count": Gte(0)}, + msg=f"'{name}.count' should be >= 0", + ) + for name in [ + "total", + "readLock", + "writeLock", + "queries", + "getmore", + "insert", + "update", + "remove", + "commands", + ] +] + +EVENT_FIELD_TESTS = EVENT_EXISTS_TESTS + EVENT_TIME_TESTS + EVENT_COUNT_TESTS + + +@pytest.mark.parametrize("test", pytest_params(EVENT_FIELD_TESTS)) +def test_top_event_field_structure(collection, test): + """Test that per-collection event fields have expected structure.""" + collection.insert_one({"_id": "event_structure_probe"}) + result = execute_admin_command(collection, {"top": 1}) + ns = f"{collection.database.name}.{collection.name}" + ns_data = result["totals"].get(ns) + if ns_data is None: + raise AssertionError(f"Namespace {ns} not found in top totals") + assertProperties(ns_data, test.checks, msg=test.msg, raw_res=True) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/__init__.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_bool_param_coercion.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_bool_param_coercion.py new file mode 100644 index 000000000..e180a2081 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_bool_param_coercion.py @@ -0,0 +1,168 @@ +"""Canonical BSON type coercion matrix for validate boolean parameters. + +Uses 'full' as the representative parameter. All boolean parameters +(full, metadata, checkBSONConformance, repair, fixMultikey) share the +same coercion logic; other per-parameter files contain only wiring tests. +""" + +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.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 + +# Property [Type Coercion]: validate accepts all BSON types for the full parameter via coercion. +ACCEPTED_TYPE_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "bool_true", + command={"full": True}, + checks={"ok": Eq(1.0)}, + msg="full should accept bool true", + ), + DiagnosticTestCase( + "bool_false", + command={"full": False}, + checks={"ok": Eq(1.0)}, + msg="full should accept bool false", + ), + DiagnosticTestCase( + "int32_1", + command={"full": 1}, + checks={"ok": Eq(1.0)}, + msg="full should accept int32 1 (coerces to true)", + ), + DiagnosticTestCase( + "int32_0", + command={"full": 0}, + checks={"ok": Eq(1.0)}, + msg="full should accept int32 0 (coerces to false)", + ), + DiagnosticTestCase( + "double_1", + command={"full": 1.0}, + checks={"ok": Eq(1.0)}, + msg="full should accept double 1.0 (coerces to true)", + ), + DiagnosticTestCase( + "double_0", + command={"full": 0.0}, + checks={"ok": Eq(1.0)}, + msg="full should accept double 0.0 (coerces to false)", + ), + DiagnosticTestCase( + "int64_1", + command={"full": Int64(1)}, + checks={"ok": Eq(1.0)}, + msg="full should accept Int64(1) (coerces to true)", + ), + DiagnosticTestCase( + "int64_0", + command={"full": Int64(0)}, + checks={"ok": Eq(1.0)}, + msg="full should accept Int64(0) (coerces to false)", + ), + DiagnosticTestCase( + "decimal128_1", + command={"full": Decimal128("1")}, + checks={"ok": Eq(1.0)}, + msg="full should accept Decimal128('1') (coerces to true)", + ), + DiagnosticTestCase( + "decimal128_0", + command={"full": Decimal128("0")}, + checks={"ok": Eq(1.0)}, + msg="full should accept Decimal128('0') (coerces to false)", + ), + DiagnosticTestCase( + "null", + command={"full": None}, + checks={"ok": Eq(1.0)}, + msg="full should accept null (treated as omitted/false)", + ), + DiagnosticTestCase( + "string", + command={"full": "true"}, + checks={"ok": Eq(1.0)}, + msg="full should accept string (coerces to truthy)", + ), + DiagnosticTestCase( + "object", + command={"full": {}}, + checks={"ok": Eq(1.0)}, + msg="full should accept object (coerces to truthy)", + ), + DiagnosticTestCase( + "array", + command={"full": []}, + checks={"ok": Eq(1.0)}, + msg="full should accept array (coerces to truthy)", + ), + DiagnosticTestCase( + "binary", + command={"full": Binary(b"")}, + checks={"ok": Eq(1.0)}, + msg="full should accept Binary (coerces to truthy)", + ), + DiagnosticTestCase( + "objectid", + command={"full": ObjectId()}, + checks={"ok": Eq(1.0)}, + msg="full should accept ObjectId (coerces to truthy)", + ), + DiagnosticTestCase( + "datetime", + command={"full": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + checks={"ok": Eq(1.0)}, + msg="full should accept datetime (coerces to truthy)", + ), + DiagnosticTestCase( + "regex", + command={"full": Regex(".*")}, + checks={"ok": Eq(1.0)}, + msg="full should accept Regex (coerces to truthy)", + ), + DiagnosticTestCase( + "timestamp", + command={"full": Timestamp(0, 0)}, + checks={"ok": Eq(1.0)}, + msg="full should accept Timestamp (coerces to truthy)", + ), + DiagnosticTestCase( + "code", + command={"full": Code("function(){}")}, + checks={"ok": Eq(1.0)}, + msg="full should accept JavaScript Code (coerces to truthy)", + ), + DiagnosticTestCase( + "minkey", + command={"full": MinKey()}, + checks={"ok": Eq(1.0)}, + msg="full should accept MinKey (coerces to truthy)", + ), + DiagnosticTestCase( + "maxkey", + command={"full": MaxKey()}, + checks={"ok": Eq(1.0)}, + msg="full should accept MaxKey (coerces to truthy)", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(ACCEPTED_TYPE_TESTS)) +def test_validate_full_accepted_types(collection, test): + """Test that validate accepts all BSON types for the full parameter.""" + collection.insert_one({"_id": 1}) + result = execute_command( + collection, + {"validate": collection.name, **test.command}, + ) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_checkBSONConformance_type.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_checkBSONConformance_type.py new file mode 100644 index 000000000..e45f5fcf4 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_checkBSONConformance_type.py @@ -0,0 +1,50 @@ +"""Wiring tests for validate command 'checkBSONConformance' parameter type coercion. + +Confirms checkBSONConformance uses the same boolean coercion as other params. +The full BSON type matrix is in test_validate_bool_param_coercion.py. +""" + +from __future__ import annotations + +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_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq + +# Property [Type Coercion Wiring]: checkBSONConformance delegates to shared boolean coercion. +WIRING_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "bool_true", + command={"checkBSONConformance": True}, + checks={"ok": Eq(1.0)}, + msg="checkBSONConformance should accept bool true", + ), + DiagnosticTestCase( + "int32_0", + command={"checkBSONConformance": 0}, + checks={"ok": Eq(1.0)}, + msg="checkBSONConformance should accept int32 0 (coerces to false)", + ), + DiagnosticTestCase( + "string", + command={"checkBSONConformance": "true"}, + checks={"ok": Eq(1.0)}, + msg="checkBSONConformance should accept string (coerces to truthy)", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(WIRING_TESTS)) +def test_validate_checkBSONConformance_accepted_types(collection, test): + """Test that checkBSONConformance uses shared boolean coercion.""" + collection.insert_one({"_id": 1}) + result = execute_command( + collection, + {"validate": collection.name, **test.command}, + ) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_core_behavior.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_core_behavior.py new file mode 100644 index 000000000..07302f948 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_core_behavior.py @@ -0,0 +1,245 @@ +"""Tests for validate command core behavior. + +Validates basic functionality, counts, consistency across calls, comment parameter, +behavior on different collection types (capped, timeseries, clustered), and valid +option combinations. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, + bind_collection, +) +from documentdb_tests.framework.assertions import assertProperties, assertSuccessPartial +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq + +# Property [Core Behavior]: validate returns expected results for populated +# and empty collections and supports common parameters. +CORE_BEHAVIOR_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "populated_collection", + setup=[{"insert": "", "documents": [{"_id": i, "x": i} for i in range(5)]}], + checks={"ok": Eq(1.0), "valid": Eq(True), "nrecords": Eq(5)}, + msg="validate should return valid: true with correct nrecords for a populated collection", + ), + DiagnosticTestCase( + "empty_collection", + setup=[{"create": ""}], + checks={ + "ok": Eq(1.0), + "valid": Eq(True), + "nrecords": Eq(0), + "nIndexes": Eq(1), + }, + msg="validate should return nrecords: 0 and nIndexes: 1 for an empty collection", + ), + DiagnosticTestCase( + "after_insert_and_delete_all", + setup=[ + {"insert": "", "documents": [{"_id": i} for i in range(5)]}, + {"delete": "", "deletes": [{"q": {}, "limit": 0}]}, + ], + checks={"ok": Eq(1.0), "valid": Eq(True), "nrecords": Eq(0)}, + msg="validate should return nrecords: 0 after deleting all documents", + ), + DiagnosticTestCase( + "after_dropping_indexes", + setup=[ + {"insert": "", "documents": [{"_id": 1, "x": 1}]}, + {"createIndexes": "", "indexes": [{"key": {"x": 1}, "name": "x_1"}]}, + {"dropIndexes": "", "index": "*"}, + ], + checks={"ok": Eq(1.0), "nIndexes": Eq(1)}, + msg="validate should return nIndexes: 1 after dropping secondary indexes", + ), + DiagnosticTestCase( + "with_comment", + setup=[{"insert": "", "documents": [{"_id": 1}]}], + command={"comment": "test comment"}, + checks={"ok": Eq(1.0)}, + msg="validate should succeed with comment parameter", + ), + DiagnosticTestCase( + "unrecognized_field_ignored", + setup=[{"insert": "", "documents": [{"_id": 1}]}], + command={"unknownField": 1}, + checks={"ok": Eq(1.0), "valid": Eq(True)}, + msg="validate should ignore unrecognized fields and succeed", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(CORE_BEHAVIOR_TESTS)) +def test_validate_core_behavior(collection, test): + """Test validate core behavior with various collection states.""" + for cmd in test.setup: + execute_command(collection, bind_collection(cmd, collection.name)) + cmd = {"validate": collection.name} + if test.command: + cmd.update(test.command) + result = execute_command(collection, cmd) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) + + +def test_validate_consistent_across_calls(collection): + """Test validate returns consistent results across multiple calls.""" + collection.insert_many([{"_id": i, "x": i} for i in range(5)]) + result1 = execute_command(collection, {"validate": collection.name}) + result2 = execute_command(collection, {"validate": collection.name}) + assertProperties( + result1, + { + "nrecords": Eq(result2["nrecords"]), + "nIndexes": Eq(result2["nIndexes"]), + "valid": Eq(result2["valid"]), + }, + raw_res=True, + msg="validate should return identical key fields across consecutive calls", + ) + + +def test_validate_reflects_modifications(collection): + """Test validate reflects modifications between calls.""" + collection.insert_many([{"_id": i} for i in range(3)]) + execute_command(collection, {"validate": collection.name}) + collection.insert_many([{"_id": i} for i in range(3, 8)]) + result2 = execute_command(collection, {"validate": collection.name}) + assertProperties( + result2, + {"nrecords": Eq(8)}, + raw_res=True, + msg="validate should reflect updated nrecords after additional inserts", + ) + + +def test_validate_capped_collection(database_client, collection): + """Test validate on a capped collection succeeds.""" + coll_name = f"{collection.name}_capped" + database_client.create_collection(coll_name, capped=True, size=1_048_576) + coll = database_client[coll_name] + coll.insert_one({"_id": 1, "x": 1}) + result = execute_command(coll, {"validate": coll.name}) + assertSuccessPartial( + result, + {"ok": 1.0, "valid": True}, + msg="validate should succeed on a capped collection", + ) + + +def test_validate_timeseries_collection(database_client, collection): + """Test validate on a time series collection succeeds.""" + coll_name = f"{collection.name}_timeseries" + database_client.create_collection( + coll_name, + timeseries={"timeField": "ts", "metaField": "meta"}, + ) + coll = database_client[coll_name] + coll.insert_one({"ts": datetime(2024, 1, 1, tzinfo=timezone.utc), "meta": "a", "v": 1}) + result = execute_command(coll, {"validate": coll.name}) + assertSuccessPartial( + result, + {"ok": 1.0}, + msg="validate should succeed on a timeseries collection", + ) + + +def test_validate_clustered_collection(database_client, collection): + """Test validate on a clustered collection succeeds.""" + coll_name = f"{collection.name}_clustered" + database_client.command( + "create", + coll_name, + clusteredIndex={"key": {"_id": 1}, "unique": True}, + ) + coll = database_client[coll_name] + coll.insert_one({"_id": 1, "x": 1}) + result = execute_command(coll, {"validate": coll.name}) + assertSuccessPartial( + result, + {"ok": 1.0}, + msg="validate should succeed on a clustered collection", + ) + + +# Property [Valid Options]: validate succeeds with each option individually +# and with compatible multi-option combinations. +VALID_OPTION_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "all_options_false", + command={"full": False, "repair": False, "metadata": False, "checkBSONConformance": False}, + checks={"ok": Eq(1.0)}, + msg="validate should succeed with all options set to false explicitly", + ), + DiagnosticTestCase( + "full_true", + command={"full": True}, + checks={"ok": Eq(1.0)}, + msg="validate with full: true should succeed", + ), + DiagnosticTestCase( + "checkBSONConformance_true", + command={"checkBSONConformance": True}, + checks={"ok": Eq(1.0)}, + msg="validate with checkBSONConformance: true should succeed", + ), + DiagnosticTestCase( + "full_with_checkBSONConformance", + command={"full": True, "checkBSONConformance": True}, + checks={"ok": Eq(1.0)}, + msg="validate with full: true and checkBSONConformance: true should succeed", + ), + DiagnosticTestCase( + "metadata_true", + command={"metadata": True}, + checks={"ok": Eq(1.0)}, + msg="validate with metadata: true should succeed", + ), +] + + +# Property [Valid Repair Options]: validate succeeds with repair/fixMultikey +# options (standalone only). +VALID_REPAIR_OPTION_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "fixMultikey_true", + command={"fixMultikey": True}, + checks={"ok": Eq(1.0)}, + msg="validate with fixMultikey: true should succeed", + ), + DiagnosticTestCase( + "repair_true", + command={"repair": True}, + checks={"ok": Eq(1.0)}, + msg="validate with repair: true should succeed", + ), + DiagnosticTestCase( + "repair_with_fixMultikey", + command={"repair": True, "fixMultikey": True}, + checks={"ok": Eq(1.0)}, + msg="validate with repair: true and fixMultikey: true should succeed", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(VALID_OPTION_TESTS)) +def test_validate_valid_options(collection, test): + """Test that validate succeeds with valid option values.""" + collection.insert_one({"_id": 1}) + result = execute_command(collection, {"validate": collection.name, **test.command}) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) + + +@pytest.mark.requires(validate_repair=True) +@pytest.mark.parametrize("test", pytest_params(VALID_REPAIR_OPTION_TESTS)) +def test_validate_valid_repair_options(collection, test): + """Test that validate succeeds with repair/fixMultikey options on standalone.""" + collection.insert_one({"_id": 1}) + result = execute_command(collection, {"validate": collection.name, **test.command}) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_edge_cases.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_edge_cases.py new file mode 100644 index 000000000..d7674fad6 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_edge_cases.py @@ -0,0 +1,178 @@ +"""Tests for validate command edge cases. + +Validates behavior with collection name edge cases, document variety, and +large collections. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, + bind_collection, +) +from documentdb_tests.framework.assertions import assertProperties, assertSuccessPartial +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq + +# Property [Document Variety]: validate succeeds for collections with diverse +# document shapes, sizes, and BSON types. +DOCUMENT_VARIETY_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "large_document_count", + setup=[ + { + "insert": "", + "documents": [{"_id": i, "x": i} for i in range(1_000)], + } + ], + checks={"ok": Eq(1.0), "valid": Eq(True), "nrecords": Eq(1_000)}, + msg="validate should report correct nrecords for large document count", + ), + DiagnosticTestCase( + "all_bson_types", + setup=[ + { + "insert": "", + "documents": [ + { + "_id": 1, + "double_val": 3.14, + "string_val": "hello", + "object_val": {"nested": 1}, + "array_val": [1, 2, 3], + "binary_val": Binary(b"data"), + "objectid_val": ObjectId(), + "bool_val": True, + "date_val": datetime(2024, 1, 1, tzinfo=timezone.utc), + "null_val": None, + "regex_val": Regex("test"), + "int32_val": 42, + "timestamp_val": Timestamp(1, 1), + "int64_val": Int64(123_456_789), + "decimal128_val": Decimal128("1.23"), + "minkey_val": MinKey(), + "maxkey_val": MaxKey(), + } + ], + } + ], + checks={"ok": Eq(1.0), "valid": Eq(True)}, + msg="validate should return valid: true for a document with all BSON types", + ), + DiagnosticTestCase( + "deeply_nested_document", + setup=[ + { + "insert": "", + "documents": [ + { + "_id": 1, + "level_0": { + "level_1": { + "level_2": { + "level_3": { + "level_4": { + "level_5": { + "level_6": { + "level_7": { + "level_8": {"level_9": {"value": "deep"}} + } + } + } + } + } + } + } + }, + } + ], + } + ], + checks={"ok": Eq(1.0), "valid": Eq(True)}, + msg="validate should return valid: true for a deeply nested document", + ), + DiagnosticTestCase( + "documents_with_arrays", + setup=[ + { + "insert": "", + "documents": [ + {"_id": 1, "arr": []}, + {"_id": 2, "arr": [1, 2, 3]}, + {"_id": 3, "arr": list(range(100))}, + ], + } + ], + checks={"ok": Eq(1.0), "valid": Eq(True), "nrecords": Eq(3)}, + msg="validate should return valid: true for documents with arrays", + ), + DiagnosticTestCase( + "documents_with_binary_data", + setup=[ + { + "insert": "", + "documents": [ + {"_id": 1, "data": Binary(b"small")}, + {"_id": 2, "data": Binary(b"\x00" * 1_024)}, + ], + } + ], + checks={"ok": Eq(1.0), "valid": Eq(True), "nrecords": Eq(2)}, + msg="validate should return valid: true for documents with binary data", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(DOCUMENT_VARIETY_TESTS)) +def test_validate_document_variety(collection, test): + """Test validate with diverse document shapes and index counts.""" + for cmd in test.setup: + execute_command(collection, bind_collection(cmd, collection.name)) + result = execute_command(collection, {"validate": collection.name}) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) + + +def test_validate_unicode_collection_name(database_client, collection): + """Test validate succeeds with a unicode collection name.""" + coll_name = f"{collection.name}_\u00e9\u00e8\u00ea" + coll = database_client[coll_name] + coll.insert_one({"_id": 1}) + result = execute_command(coll, {"validate": coll.name}) + assertSuccessPartial( + result, + {"ok": 1.0}, + msg="validate should succeed with unicode collection name", + ) + + +def test_validate_numeric_looking_collection_name(database_client, collection): + """Test validate succeeds with a numeric-looking collection name.""" + coll_name = f"{collection.name}_12345" + coll = database_client[coll_name] + coll.insert_one({"_id": 1}) + result = execute_command(coll, {"validate": coll.name}) + assertSuccessPartial( + result, + {"ok": 1.0}, + msg="validate should succeed with numeric-looking collection name", + ) + + +def test_validate_long_collection_name(database_client, collection): + """Test validate with a collection name at the max namespace length.""" + db_name = database_client.name + # Namespace is "db.coll" so max coll length is 255 - len(db_name) - 1. + max_coll_len = 255 - len(db_name) - 1 + coll_name = f"{collection.name}_" + "a" * (max_coll_len - len(collection.name) - 1) + coll = database_client[coll_name] + coll.insert_one({"_id": 1}) + result = execute_command(coll, {"validate": coll.name}) + assertSuccessPartial( + result, {"ok": 1.0}, msg="validate should succeed with a long collection name" + ) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_error_cases.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_error_cases.py new file mode 100644 index 000000000..4847bdd01 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_error_cases.py @@ -0,0 +1,283 @@ +"""Tests for validate command error cases. + +Validates that validate returns expected errors for non-string collection name +types, invalid string values, non-existent collections, views, invalid option +combinations, and truthy background values on standalone. +""" + +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.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import ( + COMMAND_NOT_SUPPORTED_ERROR, + COMMAND_NOT_SUPPORTED_ON_VIEW_ERROR, + INVALID_NAMESPACE_ERROR, + INVALID_OPTIONS_ERROR, + NAMESPACE_NOT_FOUND_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Type Rejection]: validate rejects all non-string BSON types for the collection name. +INVALID_TYPE_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "double", + command={"validate": 1.0}, + error_code=INVALID_NAMESPACE_ERROR, + msg="validate should reject double for collection name", + ), + DiagnosticTestCase( + "int32", + command={"validate": 1}, + error_code=INVALID_NAMESPACE_ERROR, + msg="validate should reject int32 for collection name", + ), + DiagnosticTestCase( + "int64", + command={"validate": Int64(1)}, + error_code=INVALID_NAMESPACE_ERROR, + msg="validate should reject int64 for collection name", + ), + DiagnosticTestCase( + "decimal128", + command={"validate": Decimal128("1")}, + error_code=INVALID_NAMESPACE_ERROR, + msg="validate should reject Decimal128 for collection name", + ), + DiagnosticTestCase( + "bool_true", + command={"validate": True}, + error_code=INVALID_NAMESPACE_ERROR, + msg="validate should reject bool true for collection name", + ), + DiagnosticTestCase( + "bool_false", + command={"validate": False}, + error_code=INVALID_NAMESPACE_ERROR, + msg="validate should reject bool false for collection name", + ), + DiagnosticTestCase( + "null", + command={"validate": None}, + error_code=INVALID_NAMESPACE_ERROR, + msg="validate should reject null for collection name", + ), + DiagnosticTestCase( + "object", + command={"validate": {"a": 1}}, + error_code=INVALID_NAMESPACE_ERROR, + msg="validate should reject object for collection name", + ), + DiagnosticTestCase( + "empty_object", + command={"validate": {}}, + error_code=INVALID_NAMESPACE_ERROR, + msg="validate should reject empty object for collection name", + ), + DiagnosticTestCase( + "array", + command={"validate": [1, 2]}, + error_code=INVALID_NAMESPACE_ERROR, + msg="validate should reject array for collection name", + ), + DiagnosticTestCase( + "empty_array", + command={"validate": []}, + error_code=INVALID_NAMESPACE_ERROR, + msg="validate should reject empty array for collection name", + ), + DiagnosticTestCase( + "binary", + command={"validate": Binary(b"data")}, + error_code=INVALID_NAMESPACE_ERROR, + msg="validate should reject Binary for collection name", + ), + DiagnosticTestCase( + "objectid", + command={"validate": ObjectId()}, + error_code=INVALID_NAMESPACE_ERROR, + msg="validate should reject ObjectId for collection name", + ), + DiagnosticTestCase( + "datetime", + command={"validate": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + error_code=INVALID_NAMESPACE_ERROR, + msg="validate should reject datetime for collection name", + ), + DiagnosticTestCase( + "regex", + command={"validate": Regex(".*")}, + error_code=INVALID_NAMESPACE_ERROR, + msg="validate should reject Regex for collection name", + ), + DiagnosticTestCase( + "timestamp", + command={"validate": Timestamp(0, 0)}, + error_code=INVALID_NAMESPACE_ERROR, + msg="validate should reject Timestamp for collection name", + ), + DiagnosticTestCase( + "code", + command={"validate": Code("function(){}")}, + error_code=INVALID_NAMESPACE_ERROR, + msg="validate should reject JavaScript Code for collection name", + ), + DiagnosticTestCase( + "minkey", + command={"validate": MinKey()}, + error_code=INVALID_NAMESPACE_ERROR, + msg="validate should reject MinKey for collection name", + ), + DiagnosticTestCase( + "maxkey", + command={"validate": MaxKey()}, + error_code=INVALID_NAMESPACE_ERROR, + msg="validate should reject MaxKey for collection name", + ), +] + + +# Property [Invalid String Values]: validate rejects invalid string values for collection name. +INVALID_STRING_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "empty_string", + command={"validate": ""}, + error_code=INVALID_NAMESPACE_ERROR, + msg="validate should reject empty string for collection name", + ), + DiagnosticTestCase( + "dollar_prefix", + command={"validate": "$invalid"}, + error_code=NAMESPACE_NOT_FOUND_ERROR, + msg="validate should return NamespaceNotFound for dollar-prefixed collection name", + ), +] + + +INVALID_ARGUMENT_TESTS = INVALID_TYPE_TESTS + INVALID_STRING_TESTS + + +@pytest.mark.parametrize("test", pytest_params(INVALID_ARGUMENT_TESTS)) +def test_validate_rejects_invalid_arguments(collection, test): + """Test that validate rejects non-string types and invalid string values.""" + result = execute_command(collection, test.command) + assertFailureCode(result, test.error_code, msg=test.msg) + + +# Property [Non-Existent Collection]: validate returns NamespaceNotFound for +# a collection that does not exist. +NON_EXISTENT_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "non_existent_collection", + error_code=NAMESPACE_NOT_FOUND_ERROR, + msg="validate should return NamespaceNotFound for a non-existent collection", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(NON_EXISTENT_TESTS)) +def test_validate_non_existent(collection, test): + """Test validate on non-existent collections returns expected error.""" + result = execute_command(collection, {"validate": f"{collection.name}_nonexistent_xyz"}) + assertFailureCode(result, test.error_code, msg=test.msg) + + +# Property [View Rejection]: validate rejects views. +def test_validate_view_rejected(database_client, collection): + """Test validate on a view returns CommandNotSupportedOnView error.""" + source_name = f"{collection.name}_view_source" + view_name = f"{collection.name}_view" + database_client.create_collection(source_name) + database_client[source_name].insert_one({"_id": 1}) + database_client.command("create", view_name, viewOn=source_name, pipeline=[]) + coll = database_client[view_name] + result = execute_command(coll, {"validate": coll.name}) + assertFailureCode( + result, + COMMAND_NOT_SUPPORTED_ON_VIEW_ERROR, + msg="validate should reject views", + ) + + +# Property [Invalid Combinations]: validate rejects incompatible option combinations. +INVALID_COMBINATION_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "metadata_with_full", + command={"metadata": True, "full": True}, + error_code=INVALID_OPTIONS_ERROR, + msg="validate should error with metadata: true and full: true", + ), + DiagnosticTestCase( + "metadata_with_repair", + command={"metadata": True, "repair": True, "fixMultikey": True}, + error_code=INVALID_OPTIONS_ERROR, + msg="validate should error with metadata: true and repair: true", + ), + DiagnosticTestCase( + "metadata_with_checkBSONConformance", + command={"metadata": True, "checkBSONConformance": True}, + error_code=INVALID_OPTIONS_ERROR, + msg="validate should error with metadata: true and checkBSONConformance: true", + ), + DiagnosticTestCase( + "checkBSONConformance_with_repair", + command={"checkBSONConformance": True, "repair": True, "fixMultikey": True}, + error_code=INVALID_OPTIONS_ERROR, + msg="validate should error with checkBSONConformance: true and repair: true", + ), + DiagnosticTestCase( + "metadata_with_background", + command={"metadata": True, "background": True}, + error_code=INVALID_OPTIONS_ERROR, + msg="validate should error with metadata: true and background: true", + ), +] + + +# Property [Truthy Standalone Error]: validate rejects truthy background +# values on standalone mode. +TRUTHY_TYPE_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "bool_true", + command={"background": True}, + error_code=COMMAND_NOT_SUPPORTED_ERROR, + msg="background: true not supported on standalone", + ), + DiagnosticTestCase( + "int32_1", + command={"background": 1}, + error_code=COMMAND_NOT_SUPPORTED_ERROR, + msg="background: int 1 (truthy) not supported on standalone", + ), + DiagnosticTestCase( + "string", + command={"background": "true"}, + error_code=COMMAND_NOT_SUPPORTED_ERROR, + msg="background: string (truthy) not supported on standalone", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(INVALID_COMBINATION_TESTS)) +def test_validate_option_errors(collection, test): + """Test that validate errors on invalid option combinations.""" + collection.insert_one({"_id": 1}) + result = execute_command(collection, {"validate": collection.name, **test.command}) + assertFailureCode(result, test.error_code, msg=test.msg) + + +@pytest.mark.requires(validate_repair=True) +@pytest.mark.parametrize("test", pytest_params(TRUTHY_TYPE_TESTS)) +def test_validate_background_truthy_rejected(collection, test): + """Test that validate rejects truthy background values on standalone.""" + collection.insert_one({"_id": 1}) + result = execute_command(collection, {"validate": collection.name, **test.command}) + assertFailureCode(result, test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_indexes.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_indexes.py new file mode 100644 index 000000000..ae027bef0 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_indexes.py @@ -0,0 +1,232 @@ +"""Tests for validate command with various index types. + +Validates that validate succeeds and correctly reports on collections with +different index types including unique, sparse, TTL, text, 2dsphere, hashed, +wildcard, compound, and partial filter indexes. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, + bind_collection, +) +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 + +# Property [Index Types]: validate succeeds and reports correct nIndexes for +# collections with various index types. +INDEX_TYPE_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "unique_index", + setup=[ + {"insert": "", "documents": [{"_id": i, "x": i} for i in range(5)]}, + { + "createIndexes": "", + "indexes": [{"key": {"x": 1}, "name": "x_1", "unique": True}], + }, + ], + checks={"ok": Eq(1.0), "valid": Eq(True), "nIndexes": Eq(2)}, + msg="validate should succeed and report unique index", + ), + DiagnosticTestCase( + "sparse_index", + setup=[ + { + "insert": "", + "documents": [{"_id": i, "x": i} for i in range(5)] + + [{"_id": i} for i in range(5, 10)], + }, + { + "createIndexes": "", + "indexes": [{"key": {"x": 1}, "name": "x_1_sparse", "sparse": True}], + }, + ], + checks={ + "ok": Eq(1.0), + "valid": Eq(True), + "nrecords": Eq(10), + "nIndexes": Eq(2), + }, + msg="validate should succeed with sparse index", + ), + DiagnosticTestCase( + "ttl_index", + setup=[ + { + "insert": "", + "documents": [ + { + "_id": 1, + "created": datetime(2024, 1, 1, tzinfo=timezone.utc), + } + ], + }, + { + "createIndexes": "", + "indexes": [ + { + "key": {"created": 1}, + "name": "created_ttl", + "expireAfterSeconds": 3600, + } + ], + }, + ], + checks={"ok": Eq(1.0), "valid": Eq(True), "nIndexes": Eq(2)}, + msg="validate should succeed and report TTL index", + ), + DiagnosticTestCase( + "text_index", + setup=[ + { + "insert": "", + "documents": [{"_id": i, "content": f"document text {i}"} for i in range(5)], + }, + { + "createIndexes": "", + "indexes": [{"key": {"content": "text"}, "name": "content_text"}], + }, + ], + checks={"ok": Eq(1.0), "valid": Eq(True)}, + msg="validate should succeed with text index", + ), + DiagnosticTestCase( + "2dsphere_index", + setup=[ + { + "insert": "", + "documents": [ + { + "_id": 1, + "location": { + "type": "Point", + "coordinates": [0.0, 0.0], + }, + } + ], + }, + { + "createIndexes": "", + "indexes": [{"key": {"location": "2dsphere"}, "name": "location_2dsphere"}], + }, + ], + checks={"ok": Eq(1.0), "valid": Eq(True)}, + msg="validate should succeed with 2dsphere index", + ), + DiagnosticTestCase( + "hashed_index", + setup=[ + {"insert": "", "documents": [{"_id": i, "x": i} for i in range(5)]}, + { + "createIndexes": "", + "indexes": [{"key": {"x": "hashed"}, "name": "x_hashed"}], + }, + ], + checks={"ok": Eq(1.0), "valid": Eq(True), "nIndexes": Eq(2)}, + msg="validate should succeed with hashed index", + ), + DiagnosticTestCase( + "wildcard_index", + setup=[ + { + "insert": "", + "documents": [{"_id": i, "a": i, "b": str(i)} for i in range(5)], + }, + { + "createIndexes": "", + "indexes": [{"key": {"$**": 1}, "name": "wildcard"}], + }, + ], + checks={"ok": Eq(1.0), "valid": Eq(True)}, + msg="validate should succeed with wildcard index", + ), + DiagnosticTestCase( + "compound_index", + setup=[ + { + "insert": "", + "documents": [{"_id": i, "a": i, "b": -i} for i in range(5)], + }, + { + "createIndexes": "", + "indexes": [{"key": {"a": 1, "b": -1}, "name": "a_1_b_neg1"}], + }, + ], + checks={"ok": Eq(1.0), "valid": Eq(True), "nIndexes": Eq(2)}, + msg="validate should succeed and report compound index", + ), + DiagnosticTestCase( + "partial_filter_index", + setup=[ + {"insert": "", "documents": [{"_id": i, "x": i} for i in range(10)]}, + { + "createIndexes": "", + "indexes": [ + { + "key": {"x": 1}, + "name": "x_partial", + "partialFilterExpression": {"x": {"$gt": 4}}, + } + ], + }, + ], + checks={"ok": Eq(1.0), "valid": Eq(True), "nIndexes": Eq(2)}, + msg="validate should succeed with partial filter index", + ), + DiagnosticTestCase( + "multiple_indexes", + setup=[ + { + "insert": "", + "documents": [{"_id": i, "a": i, "b": str(i)} for i in range(5)], + }, + { + "createIndexes": "", + "indexes": [ + {"key": {"a": 1}, "name": "a_unique", "unique": True}, + {"key": {"b": 1}, "name": "b_1"}, + {"key": {"a": 1, "b": 1}, "name": "a_1_b_1"}, + ], + }, + ], + checks={"ok": Eq(1.0), "valid": Eq(True), "nIndexes": Eq(4)}, + msg="validate should report all 4 indexes (_id + 3 secondary)", + ), + DiagnosticTestCase( + "many_indexes", + setup=[ + { + "insert": "", + "documents": [{"_id": i, "a": i, "b": i, "c": i, "d": i, "e": i} for i in range(5)], + }, + { + "createIndexes": "", + "indexes": [ + {"key": {"a": 1}, "name": "a_1"}, + {"key": {"b": 1}, "name": "b_1"}, + {"key": {"c": 1}, "name": "c_1"}, + {"key": {"d": 1}, "name": "d_1"}, + {"key": {"e": 1}, "name": "e_1"}, + ], + }, + ], + checks={"ok": Eq(1.0), "valid": Eq(True), "nIndexes": Eq(6)}, + msg="validate should report nIndexes: 6 with 5 secondary indexes", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(INDEX_TYPE_TESTS)) +def test_validate_index_types(collection, test): + """Test validate with various index types.""" + for cmd in test.setup: + execute_command(collection, bind_collection(cmd, collection.name)) + result = execute_command(collection, {"validate": collection.name}) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_metadata_type.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_metadata_type.py new file mode 100644 index 000000000..4f183e395 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_metadata_type.py @@ -0,0 +1,96 @@ +"""Wiring tests for validate 'metadata' and 'background' parameter type coercion. + +Confirms metadata uses the same boolean coercion as other params (wiring only). +The full BSON type matrix is in test_validate_bool_param_coercion.py. +Also tests that the background parameter accepts falsy BSON types. +""" + +from __future__ import annotations + +import pytest +from bson import Decimal128, 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 + +# Property [Type Coercion Wiring]: metadata delegates to shared boolean coercion. +METADATA_WIRING_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "bool_true", + command={"metadata": True}, + checks={"ok": Eq(1.0)}, + msg="metadata should accept bool true", + ), + DiagnosticTestCase( + "int32_0", + command={"metadata": 0}, + checks={"ok": Eq(1.0)}, + msg="metadata should accept int32 0 (coerces to false)", + ), + DiagnosticTestCase( + "string", + command={"metadata": "true"}, + checks={"ok": Eq(1.0)}, + msg="metadata should accept string (coerces to truthy)", + ), +] + + +# Property [Falsy Type Acceptance]: validate accepts falsy BSON types for the background parameter. +FALSY_TYPE_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "bool_false", + command={"background": False}, + checks={"ok": Eq(1.0)}, + msg="background should accept bool false", + ), + DiagnosticTestCase( + "int32_0", + command={"background": 0}, + checks={"ok": Eq(1.0)}, + msg="background should accept int32 0 (coerces to false)", + ), + DiagnosticTestCase( + "double_0", + command={"background": 0.0}, + checks={"ok": Eq(1.0)}, + msg="background should accept double 0.0 (coerces to false)", + ), + DiagnosticTestCase( + "int64_0", + command={"background": Int64(0)}, + checks={"ok": Eq(1.0)}, + msg="background should accept Int64(0) (coerces to false)", + ), + DiagnosticTestCase( + "decimal128_0", + command={"background": Decimal128("0")}, + checks={"ok": Eq(1.0)}, + msg="background should accept Decimal128('0') (coerces to false)", + ), + DiagnosticTestCase( + "null", + command={"background": None}, + checks={"ok": Eq(1.0)}, + msg="background should accept null (treated as omitted/false)", + ), +] + + +METADATA_AND_BACKGROUND_TESTS = METADATA_WIRING_TESTS + FALSY_TYPE_TESTS + + +@pytest.mark.parametrize("test", pytest_params(METADATA_AND_BACKGROUND_TESTS)) +def test_validate_metadata_and_background_types(collection, test): + """Test type coercion for metadata and background parameters.""" + collection.insert_one({"_id": 1}) + result = execute_command( + collection, + {"validate": collection.name, **test.command}, + ) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_repair.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_repair.py new file mode 100644 index 000000000..eb9531825 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_repair.py @@ -0,0 +1,106 @@ +"""Wiring tests for validate command repair and fixMultikey options. + +Confirms repair and fixMultikey use the same boolean coercion as other params +(wiring only). The full BSON type matrix is in test_validate_bool_param_coercion.py. +Also verifies repairMode values for different repair configurations. +""" + +from __future__ import annotations + +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_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq + +pytestmark = pytest.mark.requires(validate_repair=True) + +# Property [Type Coercion Wiring]: repair delegates to shared boolean coercion. +REPAIR_WIRING_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "repair_bool_true", + command={"repair": True}, + checks={"ok": Eq(1.0)}, + msg="repair should accept bool true", + ), + DiagnosticTestCase( + "repair_int32_0", + command={"repair": 0}, + checks={"ok": Eq(1.0)}, + msg="repair should accept int32 0 (coerces to false)", + ), + DiagnosticTestCase( + "repair_string", + command={"repair": "true"}, + checks={"ok": Eq(1.0)}, + msg="repair should accept string (coerces to truthy)", + ), +] + +# Property [Type Coercion Wiring]: fixMultikey delegates to shared boolean coercion. +FIXMULTIKEY_WIRING_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "fixMultikey_bool_true", + command={"fixMultikey": True}, + checks={"ok": Eq(1.0)}, + msg="fixMultikey should accept bool true", + ), + DiagnosticTestCase( + "fixMultikey_int32_0", + command={"fixMultikey": 0}, + checks={"ok": Eq(1.0)}, + msg="fixMultikey should accept int32 0 (coerces to false)", + ), + DiagnosticTestCase( + "fixMultikey_string", + command={"fixMultikey": "true"}, + checks={"ok": Eq(1.0)}, + msg="fixMultikey should accept string (coerces to truthy)", + ), +] + +# Property [Repair Mode]: validate returns correct repairMode for different configurations. +REPAIR_MODE_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "no_repair_mode_none", + command={}, + checks={"ok": Eq(1.0), "repairMode": Eq("None"), "repaired": Eq(False)}, + msg="validate should return repairMode: 'None' with no repair options", + ), + DiagnosticTestCase( + "repair_and_fixMultikey_mode_fix_errors", + command={"repair": True, "fixMultikey": True}, + checks={"ok": Eq(1.0), "repairMode": Eq("FixErrors"), "repaired": Eq(False)}, + msg="validate with repair+fixMultikey should return repairMode: 'FixErrors'", + ), + DiagnosticTestCase( + "fixMultikey_alone_mode_adjust", + command={"fixMultikey": True}, + checks={"ok": Eq(1.0), "repairMode": Eq("AdjustMultikey"), "repaired": Eq(False)}, + msg="validate with fixMultikey alone should return repairMode: 'AdjustMultikey'", + ), + DiagnosticTestCase( + "repair_alone_mode_fix_errors", + command={"repair": True}, + checks={"ok": Eq(1.0), "repairMode": Eq("FixErrors"), "repaired": Eq(False)}, + msg="validate with repair alone should return repairMode: 'FixErrors'", + ), +] + + +REPAIR_AND_FIXMULTIKEY_TESTS = REPAIR_WIRING_TESTS + FIXMULTIKEY_WIRING_TESTS + REPAIR_MODE_TESTS + + +@pytest.mark.parametrize("test", pytest_params(REPAIR_AND_FIXMULTIKEY_TESTS)) +def test_validate_repair_and_fixMultikey(collection, test): + """Test repair/fixMultikey type coercion and repairMode values.""" + collection.insert_one({"_id": 1}) + result = execute_command( + collection, + {"validate": collection.name, **test.command}, + ) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_response_structure.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_response_structure.py new file mode 100644 index 000000000..f7fb62676 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_response_structure.py @@ -0,0 +1,289 @@ +"""Tests for validate command response structure. + +Validates presence, types, and values of response fields for healthy collections, +including keysPerIndex/indexDetails structure and full/metadata mode responses. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, + bind_collection, +) +from documentdb_tests.framework.assertions import assertProperties, assertSuccessPartial +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, Gte, IsType, NotExists + +# Property [Response Structure]: validate returns expected field types and values for +# healthy collections. +PROPERTY_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "ok_is_1", + checks={"ok": Eq(1.0)}, + msg="validate should return ok: 1.0", + ), + DiagnosticTestCase( + "ns_is_string", + checks={"ns": IsType("string")}, + msg="validate should return ns as a string", + ), + DiagnosticTestCase( + "nInvalidDocuments_is_int", + checks={"nInvalidDocuments": IsType("int")}, + msg="validate should return nInvalidDocuments as an int", + ), + DiagnosticTestCase( + "nNonCompliantDocuments_is_int", + checks={"nNonCompliantDocuments": IsType("int")}, + msg="validate should return nNonCompliantDocuments as an int", + ), + DiagnosticTestCase( + "nrecords_is_int", + checks={"nrecords": IsType("int")}, + msg="validate should return nrecords as an int", + ), + DiagnosticTestCase( + "nIndexes_is_int", + checks={"nIndexes": IsType("int")}, + msg="validate should return nIndexes as an int", + ), + DiagnosticTestCase( + "keysPerIndex_is_object", + checks={"keysPerIndex": IsType("object")}, + msg="validate should return keysPerIndex as an object", + ), + DiagnosticTestCase( + "indexDetails_is_object", + checks={"indexDetails": IsType("object")}, + msg="validate should return indexDetails as an object", + ), + DiagnosticTestCase( + "valid_is_bool", + checks={"valid": IsType("bool")}, + msg="validate should return valid as a bool", + ), + DiagnosticTestCase( + "repaired_is_bool", + checks={"repaired": IsType("bool")}, + msg="validate should return repaired as a bool", + ), + DiagnosticTestCase( + "warnings_is_array", + checks={"warnings": IsType("array")}, + msg="validate should return warnings as an array", + ), + DiagnosticTestCase( + "errors_is_array", + checks={"errors": IsType("array")}, + msg="validate should return errors as an array", + ), + DiagnosticTestCase( + "extraIndexEntries_is_array", + checks={"extraIndexEntries": IsType("array")}, + msg="validate should return extraIndexEntries as an array", + ), + DiagnosticTestCase( + "missingIndexEntries_is_array", + checks={"missingIndexEntries": IsType("array")}, + msg="validate should return missingIndexEntries as an array", + ), + DiagnosticTestCase( + "corruptRecords_is_array", + checks={"corruptRecords": IsType("array")}, + msg="validate should return corruptRecords as an array", + ), + DiagnosticTestCase( + "uuid_exists", + checks={"uuid": Exists()}, + msg="validate should return uuid field", + ), + DiagnosticTestCase( + "nInvalidDocuments_zero_healthy", + checks={"nInvalidDocuments": Eq(0)}, + msg="validate should return nInvalidDocuments: 0 for a healthy collection", + ), + DiagnosticTestCase( + "nNonCompliantDocuments_zero_healthy", + checks={"nNonCompliantDocuments": Eq(0)}, + msg="validate should return nNonCompliantDocuments: 0 for a healthy collection", + ), + DiagnosticTestCase( + "valid_true_healthy", + checks={"valid": Eq(True)}, + msg="validate should return valid: true for a healthy collection", + ), + DiagnosticTestCase( + "warnings_empty_healthy", + checks={"warnings": Eq([])}, + msg="validate should return empty warnings for a healthy collection", + ), + DiagnosticTestCase( + "errors_empty_healthy", + checks={"errors": Eq([])}, + msg="validate should return empty errors for a healthy collection", + ), + DiagnosticTestCase( + "extraIndexEntries_empty_healthy", + checks={"extraIndexEntries": Eq([])}, + msg="validate should return empty extraIndexEntries for a healthy collection", + ), + DiagnosticTestCase( + "missingIndexEntries_empty_healthy", + checks={"missingIndexEntries": Eq([])}, + msg="validate should return empty missingIndexEntries for a healthy collection", + ), + DiagnosticTestCase( + "corruptRecords_empty_healthy", + checks={"corruptRecords": Eq([])}, + msg="validate should return empty corruptRecords for a healthy collection", + ), + DiagnosticTestCase( + "nIndexes_gte_1", + checks={"nIndexes": Gte(1)}, + msg="validate should return nIndexes >= 1 (at least _id index)", + ), + DiagnosticTestCase( + "repairMode_is_string", + checks={"repairMode": IsType("string")}, + msg="validate should return repairMode as a string", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(PROPERTY_TESTS)) +def test_validate_response_properties(collection, test): + """Test validate response fields have expected types and values.""" + collection.insert_many([{"_id": i, "x": i} for i in range(5)]) + result = execute_command(collection, {"validate": collection.name}) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) + + +# Property [Response Values]: validate returns correct dynamic values matching +# collection state (nrecords, nIndexes, ns). +RESPONSE_VALUE_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "nrecords_matches_count", + setup=[ + {"insert": "", "documents": [{"_id": i} for i in range(10)]}, + ], + checks={"nrecords": Eq(10)}, + msg="validate should return nrecords matching document count", + ), + DiagnosticTestCase( + "nIndexes_with_secondary", + setup=[ + {"insert": "", "documents": [{"_id": 1, "x": 1, "y": 1}]}, + { + "createIndexes": "", + "indexes": [ + {"key": {"x": 1}, "name": "x_1"}, + {"key": {"y": 1}, "name": "y_1"}, + ], + }, + ], + checks={"nIndexes": Eq(3)}, + msg="validate should return nIndexes: 3 with two secondary indexes", + ), +] + + +def test_validate_ns_matches_namespace(collection): + """Test validate ns field matches the actual database.collection namespace.""" + collection.insert_one({"_id": 1}) + result = execute_command(collection, {"validate": collection.name}) + expected_ns = f"{collection.database.name}.{collection.name}" + assertSuccessPartial( + result, + {"ns": expected_ns}, + msg="validate should return ns matching the actual namespace", + ) + + +# Property [Detailed Structure]: validate returns correct sub-structure for +# keysPerIndex, indexDetails, and option-specific response shapes. +DETAILED_STRUCTURE_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "keysPerIndex_entries", + setup=[ + {"insert": "", "documents": [{"_id": i, "x": i} for i in range(5)]}, + { + "createIndexes": "", + "indexes": [{"key": {"x": 1}, "name": "x_1"}], + }, + ], + checks={ + "keysPerIndex._id_": IsType("int"), + "keysPerIndex.x_1": IsType("int"), + }, + msg="keysPerIndex should have integer entries for each index", + ), + DiagnosticTestCase( + "indexDetails_entries", + setup=[ + {"insert": "", "documents": [{"_id": i, "x": i} for i in range(5)]}, + { + "createIndexes": "", + "indexes": [{"key": {"x": 1}, "name": "x_1"}], + }, + ], + checks={ + "indexDetails._id_.valid": Eq(True), + "indexDetails._id_.spec": IsType("object"), + "indexDetails.x_1.valid": Eq(True), + "indexDetails.x_1.spec": IsType("object"), + }, + msg="indexDetails entries should have valid: true and spec as object", + ), + DiagnosticTestCase( + "full_true_response", + setup=[{"insert": "", "documents": [{"_id": 1}]}], + command={"full": True}, + checks={ + "ok": Eq(1.0), + "valid": Eq(True), + "ns": IsType("string"), + "nrecords": IsType("int"), + "nIndexes": Gte(1), + "keysPerIndex": IsType("object"), + "indexDetails": IsType("object"), + "repairMode": Eq("None"), + }, + msg="validate with full: true should return all standard response fields", + ), + DiagnosticTestCase( + "metadata_true_response", + setup=[ + {"insert": "", "documents": [{"_id": i, "x": i} for i in range(5)]}, + ], + command={"metadata": True}, + checks={ + "ok": Eq(1.0), + "valid": Eq(True), + "ns": IsType("string"), + "nIndexes": IsType("int"), + "nrecords": NotExists(), + "nInvalidDocuments": NotExists(), + "nNonCompliantDocuments": NotExists(), + }, + msg="validate with metadata: true should return structural fields" + " but omit document-count fields", + ), +] + + +RESPONSE_DETAIL_TESTS = RESPONSE_VALUE_TESTS + DETAILED_STRUCTURE_TESTS + + +@pytest.mark.parametrize("test", pytest_params(RESPONSE_DETAIL_TESTS)) +def test_validate_response_details(collection, test): + """Test validate response values, sub-structure, and option-specific shapes.""" + for cmd in test.setup: + execute_command(collection, bind_collection(cmd, collection.name)) + cmd = {"validate": collection.name} + if test.command: + cmd.update(test.command) + result = execute_command(collection, cmd) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/utils/diagnostic_test_case.py b/documentdb_tests/compatibility/tests/system/diagnostic/utils/diagnostic_test_case.py index 3d08d60c0..65045be33 100644 --- a/documentdb_tests/compatibility/tests/system/diagnostic/utils/diagnostic_test_case.py +++ b/documentdb_tests/compatibility/tests/system/diagnostic/utils/diagnostic_test_case.py @@ -1,4 +1,4 @@ -"""Shared test case for diagnostic command tests.""" +"""Shared test case and utilities for diagnostic command tests.""" from dataclasses import dataclass, field from typing import Any, Dict, List, Optional @@ -21,3 +21,14 @@ class DiagnosticTestCase(BaseTestCase): command: Optional[Dict[str, Any]] = None use_admin: bool = True checks: Dict[str, Any] = field(default_factory=dict) + + +def bind_collection(cmd: Dict[str, Any], name: str) -> Dict[str, Any]: + """Replace the placeholder collection name in a setup command. + + Setup commands use ``""`` as a placeholder for the collection name + (e.g. ``{"insert": "", "documents": [...]}``). This helper overwrites + the first key's value with the real collection name. + """ + command_name = next(iter(cmd)) + return {**cmd, command_name: name} diff --git a/documentdb_tests/compatibility/tests/system/replication/__init__.py b/documentdb_tests/compatibility/tests/system/replication/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/system/replication/commands/__init__.py b/documentdb_tests/compatibility/tests/system/replication/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/system/replication/commands/hello/__init__.py b/documentdb_tests/compatibility/tests/system/replication/commands/hello/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/system/replication/commands/hello/test_hello_command_value.py b/documentdb_tests/compatibility/tests/system/replication/commands/hello/test_hello_command_value.py new file mode 100644 index 000000000..2d2e97cd0 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/replication/commands/hello/test_hello_command_value.py @@ -0,0 +1,71 @@ +"""Tests for hello command value type acceptance. + +Validates that the hello command accepts all standard BSON types as +the command value (the ``1`` in ``{hello: 1}``). +""" + +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, +) +from documentdb_tests.compatibility.tests.system.replication.utils.replication_test_case import ( # noqa: E501 + ReplicationTestCase, +) +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 + +# Property [Command Value Type Acceptance]: the hello command accepts +# all standard BSON types as the command value. +HELLO_COMMAND_VALUE_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + f"command_value_{tid}", + command=lambda ctx, v=val: {"hello": v}, + use_admin=False, + expected={"ok": Eq(1.0)}, + msg=f"hello should accept {tid} as command value", + ) + for tid, val in [ + ("int32", 1), + ("bool_true", True), + ("bool_false", False), + ("double", 1.0), + ("string", "test"), + ("null", None), + ("object_empty", {}), + ("object", {"key": "val"}), + ("array_empty", []), + ("array", [1, 2]), + ("int64", Int64(1)), + ("decimal128", Decimal128("1")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("binary", Binary(b"\x00", 0)), + ("objectid", ObjectId()), + ("regex", Regex(".*")), + ("timestamp", Timestamp(0, 0)), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("code", Code("function(){}")), + ] +] + + +@pytest.mark.parametrize("test", pytest_params(HELLO_COMMAND_VALUE_TESTS)) +def test_hello_command_value(collection, test): + """Test hello command value type acceptance.""" + 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/replication/commands/hello/test_hello_comment.py b/documentdb_tests/compatibility/tests/system/replication/commands/hello/test_hello_comment.py new file mode 100644 index 000000000..92a207637 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/replication/commands/hello/test_hello_comment.py @@ -0,0 +1,122 @@ +"""Tests for hello command comment parameter, combined parameters, +and unrecognized fields. + +Validates that the comment parameter accepts all BSON types, that +combined parameters work together, and that unrecognized fields are +handled correctly. +""" + +from __future__ import annotations + +import pytest +from bson import Decimal128, Int64, ObjectId + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, +) +from documentdb_tests.compatibility.tests.system.replication.utils.replication_test_case import ( # noqa: E501 + ReplicationTestCase, +) +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 + +# Property [Comment Type Acceptance]: the comment parameter accepts all +# BSON types without error. +HELLO_COMMENT_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + f"comment_{tid}", + command=lambda ctx, v=val: {"hello": 1, "comment": v}, + use_admin=False, + expected={"ok": Eq(1.0)}, + msg=f"hello should accept {tid} as comment value", + ) + for tid, val in [ + ("string", "a log comment"), + ("int32", 42), + ("double", 3.14), + ("bool_true", True), + ("bool_false", False), + ("null", None), + ("object", {"key": "val"}), + ("array", [1, "two", 3]), + ("int64", Int64(999)), + ("decimal128", Decimal128("1.5")), + ("objectid", ObjectId()), + ] +] + +# Property [Combined Parameters]: hello accepts saslSupportedMechs and +# comment together or individually. +HELLO_COMBINED_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "combined_both_params", + command=lambda ctx: { + "hello": 1, + "saslSupportedMechs": "admin.testuser", + "comment": "both params", + }, + use_admin=False, + expected={"ok": Eq(1.0)}, + msg="hello should accept both saslSupportedMechs and comment", + ), + ReplicationTestCase( + "combined_comment_only", + command=lambda ctx: {"hello": 1, "comment": "only comment"}, + use_admin=False, + expected={"ok": Eq(1.0)}, + msg="hello should succeed with only comment parameter", + ), + ReplicationTestCase( + "combined_sasl_only", + command=lambda ctx: { + "hello": 1, + "saslSupportedMechs": "admin.testuser", + }, + use_admin=False, + expected={"ok": Eq(1.0)}, + msg="hello should succeed with only saslSupportedMechs parameter", + ), +] + +# Property [Unrecognized Field Handling]: hello silently ignores +# unrecognized fields. +HELLO_UNRECOGNIZED_FIELD_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "unrecognized_single_field", + command=lambda ctx: {"hello": 1, "unknownField": "value"}, + use_admin=False, + expected={"ok": Eq(1.0)}, + msg="hello should ignore unrecognized field", + ), + ReplicationTestCase( + "unrecognized_multiple_fields", + command=lambda ctx: { + "hello": 1, + "unknownField1": 1, + "unknownField2": 2, + }, + use_admin=False, + expected={"ok": Eq(1.0)}, + msg="hello should ignore multiple unrecognized fields", + ), +] + +HELLO_COMMENT_ALL_TESTS: list[ReplicationTestCase] = ( + HELLO_COMMENT_TESTS + HELLO_COMBINED_TESTS + HELLO_UNRECOGNIZED_FIELD_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(HELLO_COMMENT_ALL_TESTS)) +def test_hello_comment(collection, test): + """Test hello comment parameter, combined parameters, and unrecognized fields.""" + 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/replication/commands/hello/test_hello_consistency.py b/documentdb_tests/compatibility/tests/system/replication/commands/hello/test_hello_consistency.py new file mode 100644 index 000000000..bf5d86c30 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/replication/commands/hello/test_hello_consistency.py @@ -0,0 +1,232 @@ +"""Tests for hello command consistency, idempotency, legacy compatibility, +execution context, standalone behavior, and read-only behavior. + +Validates that hello returns consistent results, works across databases, +is compatible with legacy isMaster, and does not modify state. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, +) +from documentdb_tests.compatibility.tests.system.replication.utils.replication_test_case import ( # noqa: E501 + ReplicationTestCase, +) +from documentdb_tests.framework.assertions import assertResult +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, Gte, NotExists + +# Property [Execution Context]: hello succeeds on any database context. +HELLO_CONTEXT_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "context_admin_database", + command=lambda ctx: {"hello": 1}, + use_admin=True, + expected={"ok": Eq(1.0)}, + msg="hello should succeed on admin database", + ), + ReplicationTestCase( + "context_user_database", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected={"ok": Eq(1.0)}, + msg="hello should succeed on non-admin database", + ), +] + +# Property [Primary/Standalone Defaults]: on a standalone or primary node, +# isWritablePrimary is true and readOnly is false. +HELLO_STANDALONE_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "primary_isWritablePrimary_true", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected={"isWritablePrimary": Eq(True)}, + msg="hello should return isWritablePrimary true on standalone/primary", + ), + ReplicationTestCase( + "primary_readOnly_false", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected={"readOnly": Eq(False)}, + msg="hello should return readOnly false on standalone/primary", + ), +] + +# Property [Legacy isMaster Compatibility]: isMaster and ismaster still work +# and return compatible output. +HELLO_LEGACY_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "legacy_isMaster_succeeds", + command=lambda ctx: {"isMaster": 1}, + use_admin=False, + expected={"ok": Eq(1.0)}, + msg="hello should support isMaster alias with ok: 1.0", + ), + ReplicationTestCase( + "legacy_ismaster_lowercase_succeeds", + command=lambda ctx: {"ismaster": 1}, + use_admin=False, + expected={"ok": Eq(1.0)}, + msg="hello should support ismaster (lowercase) alias with ok: 1.0", + ), +] + +HELLO_CONSISTENCY_ALL_TESTS: list[ReplicationTestCase] = ( + HELLO_CONTEXT_TESTS + HELLO_STANDALONE_TESTS + HELLO_LEGACY_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(HELLO_CONSISTENCY_ALL_TESTS)) +def test_hello_consistency(collection, test): + """Test hello command consistency, context, and legacy compatibility.""" + 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)) + assertResult( + result, + expected=test.build_expected(ctx, result), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + + +# Property [Static Field Consistency]: static fields are identical across +# consecutive hello calls. +def test_hello_consistency_static_fields(collection): + """Test hello returns identical static fields across consecutive calls.""" + r1 = execute_command(collection, {"hello": 1}) + r2 = execute_command(collection, {"hello": 1}) + assertResult( + r2, + expected={ + "maxBsonObjectSize": Eq(r1["maxBsonObjectSize"]), + "maxMessageSizeBytes": Eq(r1["maxMessageSizeBytes"]), + "maxWriteBatchSize": Eq(r1["maxWriteBatchSize"]), + "minWireVersion": Eq(r1["minWireVersion"]), + "maxWireVersion": Eq(r1["maxWireVersion"]), + }, + msg="hello should return identical static fields across consecutive calls", + raw_res=True, + ) + + +# Property [Admin and User DB Consistency]: static fields match between admin +# and user databases. +def test_hello_admin_and_user_db_static_fields_match(collection): + """Test hello static fields match between admin and user databases.""" + r_admin = execute_admin_command(collection, {"hello": 1}) + r_user = execute_command(collection, {"hello": 1}) + assertResult( + r_user, + expected={ + "maxBsonObjectSize": Eq(r_admin["maxBsonObjectSize"]), + "maxMessageSizeBytes": Eq(r_admin["maxMessageSizeBytes"]), + "maxWriteBatchSize": Eq(r_admin["maxWriteBatchSize"]), + "minWireVersion": Eq(r_admin["minWireVersion"]), + "maxWireVersion": Eq(r_admin["maxWireVersion"]), + }, + msg="hello should return matching static fields on admin and user db", + raw_res=True, + ) + + +# Property [localTime Monotonicity]: localTime is non-decreasing across calls. +def test_hello_localTime_monotonic(collection): + """Test hello localTime is monotonically non-decreasing.""" + r1 = execute_command(collection, {"hello": 1}) + r2 = execute_command(collection, {"hello": 1}) + assertResult( + r2, + expected={"localTime": Gte(r1["localTime"])}, + msg="hello should return non-decreasing localTime across calls", + raw_res=True, + ) + + +# Property [connectionId Stability]: connectionId is stable on the same connection. +def test_hello_connectionId_stable(collection): + """Test hello connectionId remains stable on same connection.""" + r1 = execute_command(collection, {"hello": 1}) + r2 = execute_command(collection, {"hello": 1}) + assertResult( + r2, + expected={"connectionId": Eq(r1["connectionId"])}, + msg="hello should return stable connectionId on same connection", + raw_res=True, + ) + + +# Property [Legacy Field Compatibility]: hello and isMaster return the same +# values for common fields. +def test_hello_legacy_isMaster_fields_match(collection): + """Test hello and isMaster return same values for common fields.""" + r_hello = execute_command(collection, {"hello": 1}) + r_ismaster = execute_command(collection, {"isMaster": 1}) + assertResult( + r_ismaster, + expected={ + "maxBsonObjectSize": Eq(r_hello["maxBsonObjectSize"]), + "maxMessageSizeBytes": Eq(r_hello["maxMessageSizeBytes"]), + "maxWriteBatchSize": Eq(r_hello["maxWriteBatchSize"]), + "minWireVersion": Eq(r_hello["minWireVersion"]), + "maxWireVersion": Eq(r_hello["maxWireVersion"]), + "readOnly": Eq(r_hello["readOnly"]), + "connectionId": Eq(r_hello["connectionId"]), + "logicalSessionTimeoutMinutes": Eq(r_hello["logicalSessionTimeoutMinutes"]), + }, + msg="hello should return matching fields with isMaster alias", + raw_res=True, + ) + + +# Property [Read-Only Behavior]: hello does not modify server state. +def test_hello_read_only_behavior(collection): + """Test hello static fields unchanged after inserting a document.""" + r_before = execute_command(collection, {"hello": 1}) + collection.insert_one({"_id": 1, "data": "test"}) + r_after = execute_command(collection, {"hello": 1}) + assertResult( + r_after, + expected={ + "maxBsonObjectSize": Eq(r_before["maxBsonObjectSize"]), + "maxMessageSizeBytes": Eq(r_before["maxMessageSizeBytes"]), + "maxWriteBatchSize": Eq(r_before["maxWriteBatchSize"]), + "minWireVersion": Eq(r_before["minWireVersion"]), + "maxWireVersion": Eq(r_before["maxWireVersion"]), + }, + msg="hello should return unchanged static fields after insert", + raw_res=True, + ) + + +# Property [Standalone RS Fields Absent]: on standalone, replica set fields +# are absent from the hello response. +@pytest.mark.requires(replication=False) +def test_hello_standalone_rs_fields_absent(collection): + """Test hello does not return replica set fields on standalone.""" + result = execute_command(collection, {"hello": 1}) + assertResult( + result, + expected={ + "hosts": NotExists(), + "setName": NotExists(), + "setVersion": NotExists(), + "secondary": NotExists(), + "primary": NotExists(), + "me": NotExists(), + "electionId": NotExists(), + "lastWrite": NotExists(), + "passives": NotExists(), + "arbiters": NotExists(), + }, + msg="hello should not return replica set fields on standalone", + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/system/replication/commands/hello/test_hello_error_cases.py b/documentdb_tests/compatibility/tests/system/replication/commands/hello/test_hello_error_cases.py new file mode 100644 index 000000000..823f344e3 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/replication/commands/hello/test_hello_error_cases.py @@ -0,0 +1,109 @@ +"""Tests for hello command error cases. + +Validates case-sensitive command name rejection, saslSupportedMechs +invalid format rejection, and saslSupportedMechs type rejection. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, +) +from documentdb_tests.compatibility.tests.system.replication.utils.replication_test_case import ( # noqa: E501 + ReplicationTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + COMMAND_NOT_FOUND_ERROR, + TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Case-Sensitive Command Name]: the hello command is case-sensitive +# and rejects case mismatches. +HELLO_CASE_ERROR_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "case_capital_H", + command=lambda ctx: {"Hello": 1}, + use_admin=False, + error_code=COMMAND_NOT_FOUND_ERROR, + msg="hello should reject 'Hello' (capital H)", + ), + ReplicationTestCase( + "case_all_caps", + command=lambda ctx: {"HELLO": 1}, + use_admin=False, + error_code=COMMAND_NOT_FOUND_ERROR, + msg="hello should reject 'HELLO' (all caps)", + ), + ReplicationTestCase( + "case_mixed", + command=lambda ctx: {"heLLo": 1}, + use_admin=False, + error_code=COMMAND_NOT_FOUND_ERROR, + msg="hello should reject 'heLLo' (mixed case)", + ), +] + +# Property [saslSupportedMechs Invalid Format]: hello rejects strings +# that do not follow the "db.user" format. +SASL_INVALID_FORMAT_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "sasl_no_dot_separator", + command=lambda ctx: { + "hello": 1, + "saslSupportedMechs": "noDotSeparator", + }, + use_admin=False, + error_code=BAD_VALUE_ERROR, + msg="hello should reject saslSupportedMechs without db.user format", + ), + ReplicationTestCase( + "sasl_empty_string", + command=lambda ctx: {"hello": 1, "saslSupportedMechs": ""}, + use_admin=False, + error_code=BAD_VALUE_ERROR, + msg="hello should reject empty string saslSupportedMechs", + ), +] + +# Property [saslSupportedMechs Type Rejection]: hello rejects non-string +# types for saslSupportedMechs. +SASL_TYPE_REJECTION_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + f"sasl_type_{tid}", + command=lambda ctx, v=val: {"hello": 1, "saslSupportedMechs": v}, + use_admin=False, + error_code=err, + msg=f"hello should reject {tid} as saslSupportedMechs", + ) + for tid, val, err in [ + ("int", 123, TYPE_MISMATCH_ERROR), + ("bool", True, TYPE_MISMATCH_ERROR), + ("array", ["admin.user"], TYPE_MISMATCH_ERROR), + ("object", {"db": "admin"}, BAD_VALUE_ERROR), + ("null", None, TYPE_MISMATCH_ERROR), + ] +] + +HELLO_ERROR_ALL_TESTS: list[ReplicationTestCase] = ( + HELLO_CASE_ERROR_TESTS + SASL_INVALID_FORMAT_TESTS + SASL_TYPE_REJECTION_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(HELLO_ERROR_ALL_TESTS)) +def test_hello_error_cases(collection, test): + """Test hello command error cases.""" + 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/replication/commands/hello/test_hello_replica_set.py b/documentdb_tests/compatibility/tests/system/replication/commands/hello/test_hello_replica_set.py new file mode 100644 index 000000000..06180c2e9 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/replication/commands/hello/test_hello_replica_set.py @@ -0,0 +1,190 @@ +"""Tests for hello command replica set response fields. + +Validates required replica set fields, conditional fields, and +behavioral checks when connected to a replica set member. +All tests in this file require a replica set connection. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, +) +from documentdb_tests.compatibility.tests.system.replication.utils.replication_test_case import ( # noqa: E501 + ReplicationTestCase, +) +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 ( + ContainsElement, + Eq, + Gte, + IsType, + Lte, + NonEmptyStr, +) + +pytestmark = [pytest.mark.requires(replication=True)] + +# Property [Required Replica Set Fields]: hello response includes hosts, +# setName, setVersion, secondary, primary, me, and lastWrite when connected +# to a replica set member. +RS_REQUIRED_FIELD_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "rs_hosts_is_array", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected={"hosts": IsType("array")}, + msg="hello should return hosts as array on replica set member", + ), + ReplicationTestCase( + "rs_setName", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected={"setName": NonEmptyStr()}, + msg="hello should return setName as non-empty string", + ), + ReplicationTestCase( + "rs_setVersion", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected={"setVersion": Gte(1)}, + msg="hello should return setVersion as positive integer", + ), + ReplicationTestCase( + "rs_secondary_is_bool", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected={"secondary": IsType("bool")}, + msg="hello should return secondary as boolean", + ), + ReplicationTestCase( + "rs_primary", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected={"primary": NonEmptyStr()}, + msg="hello should return primary as non-empty string", + ), + ReplicationTestCase( + "rs_me", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected={"me": NonEmptyStr()}, + msg="hello should return me as non-empty string", + ), + ReplicationTestCase( + "rs_lastWrite_opTime", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected={"lastWrite": {"opTime": IsType("object")}}, + msg="hello should return lastWrite.opTime as object", + ), + ReplicationTestCase( + "rs_lastWrite_lastWriteDate", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected={"lastWrite": {"lastWriteDate": IsType("date")}}, + msg="hello should return lastWrite.lastWriteDate as date", + ), + ReplicationTestCase( + "rs_lastWrite_majorityOpTime", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected={"lastWrite": {"majorityOpTime": IsType("object")}}, + msg="hello should return lastWrite.majorityOpTime as object", + ), + ReplicationTestCase( + "rs_lastWrite_majorityWriteDate", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected={"lastWrite": {"majorityWriteDate": IsType("date")}}, + msg="hello should return lastWrite.majorityWriteDate as date", + ), + ReplicationTestCase( + "rs_electionId_on_primary", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected={"electionId": IsType("objectId")}, + msg="hello should return electionId as ObjectId on primary", + ), +] + +# Property [Primary Node Invariants]: on a primary, isWritablePrimary is true, +# secondary is false, primary equals me, and hosts contains both. +RS_PRIMARY_INVARIANT_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "rs_primary_isWritablePrimary_and_not_secondary", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected={"isWritablePrimary": Eq(True), "secondary": Eq(False)}, + msg="hello should return isWritablePrimary=true and secondary=false on primary", + ), + ReplicationTestCase( + "rs_primary_equals_me", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected=lambda ctx, result: {"primary": Eq(result.get("me", "MISSING"))}, + msg="hello should return primary equal to me on primary node", + ), + ReplicationTestCase( + "rs_hosts_contains_primary", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected=lambda ctx, result: {"hosts": ContainsElement(result.get("primary", "MISSING"))}, + msg="hello should return hosts array containing the primary", + ), + ReplicationTestCase( + "rs_me_in_hosts", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected=lambda ctx, result: {"hosts": ContainsElement(result.get("me", "MISSING"))}, + msg="hello should return hosts array containing me", + ), +] + +# Property [lastWrite Date Ordering]: lastWrite dates have expected ordering. +RS_LASTWRITE_DATE_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "rs_lastWriteDate_not_future", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected=lambda ctx, result: { + "lastWrite": {"lastWriteDate": Lte(datetime.now(tz=timezone.utc))}, + }, + msg="hello lastWrite.lastWriteDate should be <= current time", + ), + ReplicationTestCase( + "rs_majorityWriteDate_lte_lastWriteDate", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected=lambda ctx, result: { + "lastWrite": { + "majorityWriteDate": Lte(result["lastWrite"]["lastWriteDate"]), + }, + }, + msg="hello majorityWriteDate should be <= lastWriteDate", + ), +] + +HELLO_RS_ALL_TESTS: list[ReplicationTestCase] = ( + RS_REQUIRED_FIELD_TESTS + RS_PRIMARY_INVARIANT_TESTS + RS_LASTWRITE_DATE_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(HELLO_RS_ALL_TESTS)) +def test_hello_replica_set(collection, test): + """Test hello replica set response fields.""" + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx, result), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/system/replication/commands/hello/test_hello_response_structure.py b/documentdb_tests/compatibility/tests/system/replication/commands/hello/test_hello_response_structure.py new file mode 100644 index 000000000..a3d4eec53 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/replication/commands/hello/test_hello_response_structure.py @@ -0,0 +1,165 @@ +"""Tests for hello command response structure. + +Validates common response fields (all instances), topologyVersion +fields, and wire version fields using property checks. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, +) +from documentdb_tests.compatibility.tests.system.replication.utils.replication_test_case import ( # noqa: E501 + ReplicationTestCase, +) +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, Gte, IsType + +# Property [Common Response Fields]: hello response includes all required +# fields with correct types and values. +RESPONSE_COMMON_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "response_isWritablePrimary", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected={"isWritablePrimary": IsType("bool")}, + msg="hello should return isWritablePrimary as boolean", + ), + ReplicationTestCase( + "response_maxBsonObjectSize", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected={"maxBsonObjectSize": Eq(16_777_216)}, + msg="hello should return maxBsonObjectSize equal to 16777216", + ), + ReplicationTestCase( + "response_maxMessageSizeBytes", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected={"maxMessageSizeBytes": Eq(48_000_000)}, + msg="hello should return maxMessageSizeBytes equal to 48000000", + ), + ReplicationTestCase( + "response_maxWriteBatchSize", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected={"maxWriteBatchSize": Eq(100_000)}, + msg="hello should return maxWriteBatchSize equal to 100000", + ), + ReplicationTestCase( + "response_localTime", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected={"localTime": IsType("date")}, + msg="hello should return localTime as date", + ), + ReplicationTestCase( + "response_logicalSessionTimeoutMinutes", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected={"logicalSessionTimeoutMinutes": Gte(1)}, + msg="hello should return logicalSessionTimeoutMinutes as positive integer", + ), + ReplicationTestCase( + "response_connectionId", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected={"connectionId": Gte(1)}, + msg="hello should return connectionId as positive integer", + ), + ReplicationTestCase( + "response_readOnly", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected={"readOnly": IsType("bool")}, + msg="hello should return readOnly as boolean", + ), + ReplicationTestCase( + "response_ok", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected={"ok": Eq(1.0)}, + msg="hello should return ok equal to 1.0", + ), +] + +# Property [topologyVersion]: hello response contains topologyVersion with +# processId (ObjectId) and counter (non-negative int64). +RESPONSE_TOPOLOGY_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "topology_processId", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected={"topologyVersion": {"processId": IsType("objectId")}}, + msg="hello should return topologyVersion.processId as ObjectId", + ), + ReplicationTestCase( + "topology_counter", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected={"topologyVersion": {"counter": Gte(0)}}, + msg="hello should return topologyVersion.counter as non-negative", + ), +] + +# Property [Wire Version]: wire version fields indicate protocol compatibility. +RESPONSE_WIRE_VERSION_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "wire_min_non_negative", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected={"minWireVersion": Gte(0)}, + msg="hello should return minWireVersion >= 0", + ), + ReplicationTestCase( + "wire_max_reasonable", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected={"maxWireVersion": Gte(21)}, + msg="hello should return maxWireVersion >= 21 for MongoDB 7.0+", + ), + ReplicationTestCase( + "wire_max_gte_min", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected=lambda ctx, result: { + "maxWireVersion": Gte(result.get("minWireVersion", 0)), + }, + msg="hello should return maxWireVersion >= minWireVersion", + ), +] + +HELLO_RESPONSE_ALL_TESTS: list[ReplicationTestCase] = ( + RESPONSE_COMMON_TESTS + RESPONSE_TOPOLOGY_TESTS + RESPONSE_WIRE_VERSION_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(HELLO_RESPONSE_ALL_TESTS)) +def test_hello_response_structure(collection, test): + """Test hello response field types and values.""" + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx, result), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + + +# Property [topologyVersion Stability]: processId remains stable across calls. +def test_hello_response_topologyVersion_processId_stable(collection): + """Test hello topologyVersion.processId is stable across calls.""" + r1 = execute_command(collection, {"hello": 1}) + r2 = execute_command(collection, {"hello": 1}) + assertResult( + r2, + expected={"topologyVersion": {"processId": Eq(r1["topologyVersion"]["processId"])}}, + msg="hello should return stable topologyVersion.processId across calls", + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/system/replication/commands/hello/test_hello_sasl_supported_mechs.py b/documentdb_tests/compatibility/tests/system/replication/commands/hello/test_hello_sasl_supported_mechs.py new file mode 100644 index 000000000..1943ecfcd --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/replication/commands/hello/test_hello_sasl_supported_mechs.py @@ -0,0 +1,140 @@ +"""Tests for hello command saslSupportedMechs parameter acceptance. + +Validates valid usage, format edge cases, and accepted variations +for the saslSupportedMechs parameter. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, +) +from documentdb_tests.compatibility.tests.system.replication.utils.replication_test_case import ( # noqa: E501 + ReplicationTestCase, +) +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 + +# Property [saslSupportedMechs Valid Usage]: hello accepts valid +# "db.user" format strings for saslSupportedMechs. +SASL_VALID_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "sasl_nonexistent_user", + command=lambda ctx: { + "hello": 1, + "saslSupportedMechs": "admin.nonExistentUser", + }, + use_admin=False, + expected={"ok": Eq(1.0)}, + msg="hello should succeed for non-existent user in saslSupportedMechs", + ), + ReplicationTestCase( + "sasl_other_db_prefix", + command=lambda ctx: { + "hello": 1, + "saslSupportedMechs": "otherdb.someuser", + }, + use_admin=False, + expected={"ok": Eq(1.0)}, + msg="hello should accept non-admin database prefix in saslSupportedMechs", + ), +] + +# Property [saslSupportedMechs Format Edge Cases]: hello accepts +# borderline "db.user" format variations that still contain a dot. +SASL_FORMAT_EDGE_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "sasl_empty_db_component", + command=lambda ctx: { + "hello": 1, + "saslSupportedMechs": ".noDatabase", + }, + use_admin=False, + expected={"ok": Eq(1.0)}, + msg="hello should accept saslSupportedMechs with empty database component", + ), + ReplicationTestCase( + "sasl_empty_username", + command=lambda ctx: { + "hello": 1, + "saslSupportedMechs": "admin.", + }, + use_admin=False, + expected={"ok": Eq(1.0)}, + msg="hello should accept saslSupportedMechs with empty username", + ), + ReplicationTestCase( + "sasl_dot_only", + command=lambda ctx: {"hello": 1, "saslSupportedMechs": "."}, + use_admin=False, + expected={"ok": Eq(1.0)}, + msg="hello should accept dot-only saslSupportedMechs", + ), +] + +# Property [saslSupportedMechs Edge Cases]: hello handles edge cases +# in the "db.user" format string. +SASL_EDGE_CASE_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "sasl_dots_in_username", + command=lambda ctx: { + "hello": 1, + "saslSupportedMechs": "admin.user.with.dots", + }, + use_admin=False, + expected={"ok": Eq(1.0)}, + msg="hello should accept saslSupportedMechs with dots in username", + ), + ReplicationTestCase( + "sasl_special_chars_in_db", + command=lambda ctx: { + "hello": 1, + "saslSupportedMechs": "a]dmin.user", + }, + use_admin=False, + expected={"ok": Eq(1.0)}, + msg="hello should accept saslSupportedMechs with special chars in db name", + ), + ReplicationTestCase( + "sasl_long_username", + command=lambda ctx: { + "hello": 1, + "saslSupportedMechs": "admin.a_very_long_username_that_exceeds_typical_lengths", + }, + use_admin=False, + expected={"ok": Eq(1.0)}, + msg="hello should accept saslSupportedMechs with long username", + ), + ReplicationTestCase( + "sasl_external_db", + command=lambda ctx: { + "hello": 1, + "saslSupportedMechs": "$external.user", + }, + use_admin=False, + expected={"ok": Eq(1.0)}, + msg="hello should accept $external database prefix in saslSupportedMechs", + ), +] + +HELLO_SASL_ALL_TESTS: list[ReplicationTestCase] = ( + SASL_VALID_TESTS + SASL_FORMAT_EDGE_TESTS + SASL_EDGE_CASE_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(HELLO_SASL_ALL_TESTS)) +def test_hello_sasl_supported_mechs(collection, test): + """Test hello saslSupportedMechs parameter acceptance.""" + 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/replication/commands/hello/test_smoke_hello.py b/documentdb_tests/compatibility/tests/system/replication/commands/hello/test_smoke_hello.py new file mode 100644 index 000000000..d7746c83a --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/replication/commands/hello/test_smoke_hello.py @@ -0,0 +1,21 @@ +""" +Smoke test for hello command. + +Tests basic hello command functionality by verifying the command returns +a successful response with ok: 1.0. +""" + +import pytest + +from documentdb_tests.framework.assertions import assertSuccessPartial +from documentdb_tests.framework.executor import execute_command + +pytestmark = [pytest.mark.smoke] + + +def test_smoke_hello(collection): + """Test basic hello command behavior.""" + result = execute_command(collection, {"hello": 1}) + + expected = {"ok": 1.0} + assertSuccessPartial(result, expected, msg="hello should return ok: 1.0") diff --git a/documentdb_tests/compatibility/tests/system/replication/utils/replication_test_case.py b/documentdb_tests/compatibility/tests/system/replication/utils/replication_test_case.py index 5b93302e2..449e89bfa 100644 --- a/documentdb_tests/compatibility/tests/system/replication/utils/replication_test_case.py +++ b/documentdb_tests/compatibility/tests/system/replication/utils/replication_test_case.py @@ -2,9 +2,12 @@ from __future__ import annotations +import inspect from dataclasses import dataclass +from typing import Any from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, CommandTestCase, ) @@ -16,6 +19,10 @@ class ReplicationTestCase(CommandTestCase): Extends CommandTestCase with a ``use_admin`` flag that controls whether the command is executed against the admin database. + The ``expected`` field supports an extended callable signature + ``(ctx, result) -> dict`` for assertions that reference dynamic + values from the command result itself. + Attributes: use_admin: If True (the default), execute against the admin database via ``execute_admin_command``. If False, execute @@ -23,3 +30,20 @@ class ReplicationTestCase(CommandTestCase): """ use_admin: bool = True + + def build_expected( + self, + ctx: CommandContext, + result: dict[str, Any] | None = None, + ) -> dict[str, Any] | list[dict[str, Any]] | None: + """Resolve expected, optionally passing the command result. + + If ``expected`` is a callable that accepts two parameters + (ctx, result), the result is forwarded. Otherwise, falls + back to the parent implementation. + """ + if callable(self.expected) and not isinstance(self.expected, (dict, list)): + sig = inspect.signature(self.expected) + if len(sig.parameters) == 2: + return self.expected(ctx, result) + return super().build_expected(ctx) diff --git a/documentdb_tests/framework/error_codes.py b/documentdb_tests/framework/error_codes.py index 423b3fe65..78a86b0c1 100644 --- a/documentdb_tests/framework/error_codes.py +++ b/documentdb_tests/framework/error_codes.py @@ -14,9 +14,11 @@ OVERFLOW_ERROR = 15 INVALID_LENGTH_ERROR = 16 ILLEGAL_OPERATION_ERROR = 20 +CONFLICTING_OPERATION_IN_PROGRESS_ERROR = 23 NAMESPACE_NOT_FOUND_ERROR = 26 INDEX_NOT_FOUND_ERROR = 27 PATH_NOT_VIABLE_ERROR = 28 +FILE_RENAME_FAILED_ERROR = 37 CONFLICTING_UPDATE_OPERATORS_ERROR = 40 CURSOR_NOT_FOUND_ERROR = 43 NO_MATCHING_DOCUMENT_ERROR = 47 @@ -35,6 +37,7 @@ INDEX_OPTIONS_CONFLICT_ERROR = 85 INDEX_KEY_SPECS_CONFLICT_ERROR = 86 OPERATION_FAILED_ERROR = 96 +COMMAND_NOT_SUPPORTED_ERROR = 115 DOCUMENT_VALIDATION_FAILURE_ERROR = 121 NOT_A_REPLICA_SET_ERROR = 123 CAPPED_POSITION_LOST_ERROR = 136 @@ -60,6 +63,7 @@ QUERY_EXCEEDED_MEMORY_NO_DISK_USE_ERROR = 292 API_VERSION_ERROR = 322 API_STRICT_ERROR = 323 +CANNOT_CONVERT_INDEX_TO_UNIQUE_ERROR = 359 COLLECTION_UUID_MISMATCH_ERROR = 361 QUERYSETTINGS_QUERY_REJECTED_ERROR = 411 EXPRESSION_NOT_OBJECT_ERROR = 10065 @@ -155,6 +159,7 @@ DATETOSTRING_YEAR_RANGE_ERROR = 18537 DATETOSTRING_MISSING_DATE_ERROR = 18628 DATETOSTRING_NON_OBJECT_ERROR = 18629 +KILLOP_OPID_NOT_INT32_ERROR = 26823 EMPTY_DB_NAME_ERROR = 28539 FILTER_NON_OBJECT_ARG_ERROR = 28646 FILTER_UNKNOWN_FIELD_ERROR = 28647 @@ -402,6 +407,7 @@ PROJECT_EMPTY_SUB_PROJECTION_ERROR = 51270 PROJECT_EMPTY_SPEC_ERROR = 51272 MERGE_LET_RESERVED_NEW_ERROR = 51273 +NOT_SUPPORTED_ON_STANDALONE_ERROR = 51300 REPLACE_MISSING_REPLACEMENT_ERROR = 51747 REPLACE_MISSING_FIND_ERROR = 51748 REPLACE_MISSING_INPUT_ERROR = 51749 @@ -507,6 +513,7 @@ ENCRYPTED_FIELD_DUPLICATE_PATH_ERROR = 6338402 ENCRYPTED_FIELD_UNSUPPORTED_TYPE_ERROR = 6338406 ENCRYPTED_FIELD_VIEW_TIMESERIES_ERROR = 6346401 +NOT_ENCRYPTED_COLLECTION_ERROR = 6346807 ENCRYPTED_FIELD_CAPPED_ERROR = 6367301 APPLYOPS_PRECONDITION_NOT_SUPPORTED_ERROR = 6711600 APPLYOPS_ALWAYS_UPSERT_NOT_SUPPORTED_ERROR = 6711601 @@ -516,6 +523,7 @@ WILDCARD_MULTIPLE_FIELDS_ERROR = 7246201 WILDCARD_STRING_TYPE_ERROR = 7246202 OUT_TIMESERIES_COLLECTION_TYPE_ERROR = 7268700 +MISSING_COMPACT_TOKEN_ERROR = 7294900 OUT_TIMESERIES_OPTIONS_MISMATCH_ERROR = 7406103 SORT_DUPLICATE_KEY_ERROR = 7472500 N_ACCUMULATOR_INVALID_N_ERROR = 7548606 diff --git a/documentdb_tests/framework/executor.py b/documentdb_tests/framework/executor.py index 4ce374799..49febfa3b 100644 --- a/documentdb_tests/framework/executor.py +++ b/documentdb_tests/framework/executor.py @@ -2,6 +2,7 @@ Unified execution and assertion utilities for tests. """ +import time from datetime import timezone from typing import Any, Dict @@ -50,3 +51,31 @@ def execute_admin_command(collection, command: Dict, session=None) -> Any: return result except Exception as e: return e + + +def execute_admin_with_retry_command( + collection, command: Dict, *, retry_code: int, timeout: float = 30.0, interval: float = 0.2 +) -> Any: + """ + Run an admin command, retrying while it fails with ``retry_code``. + + Any other result (success or a different error) is returned immediately. On + timeout, the last result is returned as-is. + + Args: + collection: DocumentDB collection + command: Command to execute via runCommand on the admin database + retry_code: Error code to treat as transient and retry past + timeout: Maximum seconds to keep retrying before returning the last result + interval: Seconds to wait between attempts + + Returns: + Result if successful, Exception if failed + """ + deadline = time.monotonic() + timeout + while True: + result = execute_admin_command(collection, command) + should_retry = isinstance(result, Exception) and getattr(result, "code", None) == retry_code + if not should_retry or time.monotonic() >= deadline: + return result + time.sleep(interval) diff --git a/documentdb_tests/framework/preconditions.py b/documentdb_tests/framework/preconditions.py index 8c3bf2534..a42e71716 100644 --- a/documentdb_tests/framework/preconditions.py +++ b/documentdb_tests/framework/preconditions.py @@ -53,10 +53,15 @@ "a quorum write concern is accepted (reported as a writeConcernError) rather than " "rejected up front" ), + "oplog": "a replicated oplog (local.oplog.rs) exists", "unforced_compact": "compact succeeds without force", "reindex": "reIndex is permitted", "local_rename": "renaming into the unreplicated local database is permitted", "replication": "replication commands are available (applyOps, oplog access)", + "validate_repair": ( + "validate with repair/fixMultikey is permitted and background validation " + "is rejected (standalone-only behavior)" + ), } # The capabilities each (engine, topology) target has. To add an engine or @@ -71,6 +76,7 @@ "cluster_time", "cluster_read_concern", "quorum_write_concern", + "oplog", "replication", } ), @@ -79,6 +85,7 @@ "unforced_compact", "reindex", "local_rename", + "validate_repair", } ), ("documentdb", "standalone"): frozenset( @@ -93,6 +100,7 @@ "unforced_compact", "reindex", "replication", + "validate_repair", } ), } diff --git a/documentdb_tests/framework/property_checks.py b/documentdb_tests/framework/property_checks.py index 0f029cb54..ef80dfb80 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 LenLte(Check): + """Assert that the field is a list whose length is at most ``maximum``.""" + + def __init__(self, maximum: int) -> None: + self.maximum = maximum + + def check(self, value: Any, path: str) -> str | None: + if value is _FIELD_ABSENT: + return f"expected '{path}' to have length <= {self.maximum}, 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.maximum: + return f"expected '{path}' length <= {self.maximum}, got {len(value)}" + return None + + def __repr__(self) -> str: + return f"{type(self).__name__}({self.maximum!r})" + + class Contains(Check): """Assert that a list contains a dict where ``key`` equals ``value``.""" @@ -311,6 +330,23 @@ def __repr__(self) -> str: return f"{type(self).__name__}({self.minimum!r})" +class Lte(Check): + """Assert that the field is less than or equal to a value.""" + + def __init__(self, maximum: Any) -> None: + self.maximum = maximum + + def check(self, value: Any, path: str) -> str | None: + if value is _FIELD_ABSENT: + return f"expected '{path}' <= {self.maximum!r}, but field is missing" + if value > self.maximum: + return f"expected '{path}' <= {self.maximum!r}, got {value!r}" + return None + + def __repr__(self) -> str: + return f"{type(self).__name__}({self.maximum!r})" + + class NonEmptyStr(Check): """Assert that the field is a non-empty string. diff --git a/documentdb_tests/framework/test_constants.py b/documentdb_tests/framework/test_constants.py index 4583984d7..02002c2aa 100644 --- a/documentdb_tests/framework/test_constants.py +++ b/documentdb_tests/framework/test_constants.py @@ -86,6 +86,10 @@ STRING_SIZE_LIMIT_BYTES = 16 * 1024 * 1024 REGEX_PATTERN_LIMIT_BYTES = 16 * 1024 CLUSTERED_RECORD_ID_LIMIT_BYTES = 8 * 1024 * 1024 +CAPPED_SIZE_LIMIT_BYTES = 2**50 + +# An op id used by killOp tests +NON_RUNNING_OP_ID = 999999999 # Int32 lists NUMERIC_INT32_NEGATIVE = [INT32_UNDERFLOW, INT32_MIN] diff --git a/documentdb_tests/framework/test_format_validator.py b/documentdb_tests/framework/test_format_validator.py index 295b1351d..4d224c66b 100644 --- a/documentdb_tests/framework/test_format_validator.py +++ b/documentdb_tests/framework/test_format_validator.py @@ -32,6 +32,7 @@ def validate_test_format(file_path: str) -> list[str]: "execute_project_with_insert", "execute_expression", "execute_expression_with_insert", + "execute_admin_with_retry_command", ] ) diff --git a/documentdb_tests/framework/test_structure_validator.py b/documentdb_tests/framework/test_structure_validator.py index 57f811f08..dd40a3f75 100644 --- a/documentdb_tests/framework/test_structure_validator.py +++ b/documentdb_tests/framework/test_structure_validator.py @@ -15,7 +15,7 @@ def validate_python_files_in_tests(tests_dir: Path) -> list[str]: allowed_folders = {"utils", "fixtures", "__pycache__"} for py_file in tests_dir.rglob("*.py"): - if py_file.name == "__init__.py": + if py_file.name in ("__init__.py", "conftest.py"): continue if any(folder in py_file.parts for folder in allowed_folders): continue From 07016ed397a8ee9366147f71f7945a99ea6c2eb1 Mon Sep 17 00:00:00 2001 From: PatersonProjects Date: Wed, 8 Jul 2026 13:47:53 -0700 Subject: [PATCH 2/4] Base Correlated Tests Signed-off-by: PatersonProjects --- .../stages/lookup/correlated/__init__.py | 0 ...test_lookup_concise_correlated_subquery.py | 0 .../test_lookup_correlated_collation.py | 220 +++++ .../test_lookup_correlated_composition.py | 263 ++++++ .../test_lookup_correlated_concise.py | 134 +++ .../test_lookup_correlated_edge_cases.py | 356 +++++++ .../test_lookup_correlated_let_forms.py | 760 +++++++++++++++ .../test_lookup_correlated_match.py | 872 ++++++++++++++++++ .../test_lookup_correlated_nested.py | 648 +++++++++++++ .../test_lookup_correlated_project.py | 639 +++++++++++++ ...test_lookup_correlated_result_semantics.py | 276 ++++++ .../test_lookup_correlated_subquery.py | 0 .../test_lookup_correlated_system_vars.py | 191 ++++ 13 files changed, 4359 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/__init__.py rename documentdb_tests/compatibility/tests/core/operator/stages/lookup/{ => correlated}/test_lookup_concise_correlated_subquery.py (100%) create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_collation.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_composition.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_concise.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_edge_cases.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_let_forms.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_match.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_nested.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_project.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_result_semantics.py rename documentdb_tests/compatibility/tests/core/operator/stages/lookup/{ => correlated}/test_lookup_correlated_subquery.py (100%) create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_system_vars.py diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/__init__.py b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_concise_correlated_subquery.py b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_concise_correlated_subquery.py similarity index 100% rename from documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_concise_correlated_subquery.py rename to documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_concise_correlated_subquery.py diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_collation.py b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_collation.py new file mode 100644 index 000000000..179f835d7 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_collation.py @@ -0,0 +1,220 @@ +"""Tests for $lookup correlated subquery — collation interactions. + +Covers command-level collation propagation into sub-pipeline $expr comparisons, +collation through nested lookups, and collation edge cases. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.stages.lookup.utils.lookup_common import ( + FOREIGN, + LookupTestCase, + build_lookup_command, + build_lookup_pipeline, + setup_lookup, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# --- Section 1: Command-Level Collation in Sub-Pipeline $expr --- + +LOOKUP_COLLATION_BASIC_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "collation_strength1_case_insensitive_match", + docs=[{"_id": 1, "name": "Apple"}], + foreign_docs=[ + {"_id": 10, "product": "apple"}, + {"_id": 11, "product": "banana"}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"n": "$name"}, + "pipeline": [{"$match": {"$expr": {"$eq": ["$product", "$$n"]}}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "name": "Apple", + "joined": [{"_id": 10, "product": "apple"}], + } + ], + msg=( + "$lookup $expr $eq with collation strength 1 should match" + " case-insensitively: 'Apple' == 'apple'" + ), + ), + LookupTestCase( + "no_collation_binary_comparison", + docs=[{"_id": 1, "name": "Apple"}], + foreign_docs=[ + {"_id": 10, "product": "apple"}, + {"_id": 11, "product": "Apple"}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"n": "$name"}, + "pipeline": [{"$match": {"$expr": {"$eq": ["$product", "$$n"]}}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "name": "Apple", + "joined": [{"_id": 11, "product": "Apple"}], + } + ], + msg=( + "$lookup $expr $eq without collation should use binary" + " comparison: only exact case match" + ), + ), + LookupTestCase( + "collation_strength1_lt_ordering", + docs=[{"_id": 1, "threshold": "b"}], + foreign_docs=[ + {"_id": 10, "val": "A"}, + {"_id": 11, "val": "C"}, + {"_id": 12, "val": "a"}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"thr": "$threshold"}, + "pipeline": [{"$match": {"$expr": {"$lt": ["$val", "$$thr"]}}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "threshold": "b", + "joined": [ + {"_id": 10, "val": "A"}, + {"_id": 12, "val": "a"}, + ], + } + ], + msg=( + "$lookup $expr $lt with collation strength 1 should order" + " strings case-insensitively: 'A' and 'a' both < 'b'" + ), + ), +] + +# --- Section 2: Collation Does NOT Affect Output Values --- + +LOOKUP_COLLATION_OUTPUT_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "collation_does_not_alter_addFields_output", + docs=[{"_id": 1, "label": "Apple"}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "$label"}, + "pipeline": [{"$addFields": {"val": "$$x"}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "label": "Apple", + "joined": [{"_id": 10, "val": "Apple"}], + } + ], + msg=( + "$lookup collation should NOT alter $addFields output values" + " — 'Apple' is stored as 'Apple' regardless of collation" + ), + ), +] + + +def _build_collation_command(collection, test_case, foreign_name, collation): + """Build aggregate command with collation option.""" + return { + "aggregate": collection.name, + "pipeline": build_lookup_pipeline(test_case, foreign_name), + "cursor": {}, + "collation": collation, + } + + +# Tests that require specific collation settings are run with custom commands. +# The parametrized tests below use the default (no collation / binary). +# Collation-specific tests are separate functions below. + +LOOKUP_COLLATION_NO_COLLATION_TESTS: list[LookupTestCase] = [ + LOOKUP_COLLATION_BASIC_TESTS[1], # no_collation_binary_comparison + LOOKUP_COLLATION_OUTPUT_TESTS[0], # does_not_alter_output +] + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(LOOKUP_COLLATION_NO_COLLATION_TESTS)) +def test_lookup_correlated_collation_binary(collection, test_case: LookupTestCase): + """Test $lookup correlated subquery with default binary collation.""" + with setup_lookup(collection, test_case) as foreign_name: + command = build_lookup_command(collection, test_case, foreign_name) + result = execute_command(collection, command) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) + + +@pytest.mark.aggregate +def test_lookup_correlated_collation_strength1_eq(collection): + """Test $lookup $expr $eq with collation strength 1 matches case-insensitively.""" + test_case = LOOKUP_COLLATION_BASIC_TESTS[0] + with setup_lookup(collection, test_case) as foreign_name: + command = _build_collation_command( + collection, + test_case, + foreign_name, + {"locale": "en", "strength": 1}, + ) + result = execute_command(collection, command) + assertResult( + result, + expected=test_case.expected, + msg=test_case.msg, + ) + + +@pytest.mark.aggregate +def test_lookup_correlated_collation_strength1_lt(collection): + """Test $lookup $expr $lt with collation strength 1 orders case-insensitively.""" + test_case = LOOKUP_COLLATION_BASIC_TESTS[2] + with setup_lookup(collection, test_case) as foreign_name: + command = _build_collation_command( + collection, + test_case, + foreign_name, + {"locale": "en", "strength": 1}, + ) + result = execute_command(collection, command) + assertResult( + result, + expected=test_case.expected, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_composition.py b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_composition.py new file mode 100644 index 000000000..8cfc9350c --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_composition.py @@ -0,0 +1,263 @@ +"""Tests for $lookup correlated subquery — sub-pipeline composition with other stages. + +Verifies let variables remain accessible when the sub-pipeline contains +various aggregation stages in sequence ($unwind, $group, $sort, $limit, $facet). +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.stages.lookup.utils.lookup_common import ( + FOREIGN, + LookupTestCase, + build_lookup_command, + setup_lookup, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +LOOKUP_COMPOSITION_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "let_var_after_unwind", + docs=[{"_id": 1, "wantType": "A"}], + foreign_docs=[ + {"_id": 10, "items": [{"type": "A", "v": 1}, {"type": "B", "v": 2}]}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"t": "$wantType"}, + "pipeline": [ + {"$unwind": "$items"}, + {"$match": {"$expr": {"$eq": ["$items.type", "$$t"]}}}, + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "wantType": "A", + "joined": [{"_id": 10, "items": {"type": "A", "v": 1}}], + } + ], + msg="$lookup let var should be accessible in $match after $unwind in sub-pipeline", + ), + LookupTestCase( + "let_var_after_group", + docs=[{"_id": 1, "minTotal": 100}], + foreign_docs=[ + {"_id": 10, "cat": "A", "amount": 60}, + {"_id": 11, "cat": "A", "amount": 50}, + {"_id": 12, "cat": "B", "amount": 30}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"min": "$minTotal"}, + "pipeline": [ + {"$group": {"_id": "$cat", "total": {"$sum": "$amount"}}}, + {"$match": {"$expr": {"$gte": ["$total", "$$min"]}}}, + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "minTotal": 100, + "joined": [{"_id": "A", "total": 110}], + } + ], + msg="$lookup let var should be accessible in $match after $group in sub-pipeline", + ), + LookupTestCase( + "let_var_in_addFields_then_sort", + docs=[{"_id": 1, "target": 70}], + foreign_docs=[ + {"_id": 10, "score": 60}, + {"_id": 11, "score": 80}, + {"_id": 12, "score": 75}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"tgt": "$target"}, + "pipeline": [ + {"$addFields": {"diff": {"$abs": {"$subtract": ["$score", "$$tgt"]}}}}, + {"$sort": {"diff": 1}}, + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "target": 70, + "joined": [ + {"_id": 12, "score": 75, "diff": 5}, + {"_id": 10, "score": 60, "diff": 10}, + {"_id": 11, "score": 80, "diff": 10}, + ], + } + ], + msg=( + "$lookup let var in $addFields producing diff then $sort" + " should order by proximity to let var target" + ), + ), + LookupTestCase( + "let_var_top_n_pattern", + docs=[{"_id": 1, "cat": "A"}], + foreign_docs=[ + {"_id": 10, "type": "A", "score": 50}, + {"_id": 11, "type": "A", "score": 90}, + {"_id": 12, "type": "A", "score": 70}, + {"_id": 13, "type": "B", "score": 95}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"c": "$cat"}, + "pipeline": [ + {"$match": {"$expr": {"$eq": ["$type", "$$c"]}}}, + {"$sort": {"score": -1}}, + {"$limit": 2}, + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "cat": "A", + "joined": [ + {"_id": 11, "type": "A", "score": 90}, + {"_id": 12, "type": "A", "score": 70}, + ], + } + ], + msg=( + "$lookup correlated top-N pattern: $match with let var," + " $sort, $limit should return top N matching docs" + ), + ), + LookupTestCase( + "let_var_reused_in_multiple_stages", + docs=[{"_id": 1, "threshold": 50}], + foreign_docs=[ + {"_id": 10, "score": 80}, + {"_id": 11, "score": 30}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"thr": "$threshold"}, + "pipeline": [ + {"$match": {"$expr": {"$gte": ["$score", "$$thr"]}}}, + {"$addFields": {"above_by": {"$subtract": ["$score", "$$thr"]}}}, + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "threshold": 50, + "joined": [{"_id": 10, "score": 80, "above_by": 30}], + } + ], + msg=( + "$lookup same let var used in $match and $addFields should" + " be accessible in both stages" + ), + ), + LookupTestCase( + "three_different_let_vars_in_sequence", + docs=[{"_id": 1, "minScore": 50, "label": "high", "multiplier": 2}], + foreign_docs=[ + {"_id": 10, "score": 80}, + {"_id": 11, "score": 30}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"min": "$minScore", "lbl": "$label", "mult": "$multiplier"}, + "pipeline": [ + {"$match": {"$expr": {"$gte": ["$score", "$$min"]}}}, + {"$addFields": {"tag": "$$lbl"}}, + {"$addFields": {"scaled": {"$multiply": ["$score", "$$mult"]}}}, + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "minScore": 50, + "label": "high", + "multiplier": 2, + "joined": [{"_id": 10, "score": 80, "tag": "high", "scaled": 160}], + } + ], + msg=( + "$lookup pipeline using 3 different let vars in sequential" + " stages should access all correctly" + ), + ), + LookupTestCase( + "let_var_after_project", + docs=[{"_id": 1, "suffix": "_done"}], + foreign_docs=[{"_id": 10, "name": "task", "extra": "ignored"}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"sfx": "$suffix"}, + "pipeline": [ + {"$project": {"name": 1, "_id": 0}}, + {"$addFields": {"full": {"$concat": ["$name", "$$sfx"]}}}, + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "suffix": "_done", + "joined": [{"name": "task", "full": "task_done"}], + } + ], + msg="$lookup let var should be accessible in $addFields after $project in sub-pipeline", + ), +] + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(LOOKUP_COMPOSITION_TESTS)) +def test_lookup_correlated_composition(collection, test_case: LookupTestCase): + """Test $lookup correlated subquery sub-pipeline composition with other stages.""" + with setup_lookup(collection, test_case) as foreign_name: + command = build_lookup_command(collection, test_case, foreign_name) + result = execute_command(collection, command) + 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/lookup/correlated/test_lookup_correlated_concise.py b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_concise.py new file mode 100644 index 000000000..8e12fb9b7 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_concise.py @@ -0,0 +1,134 @@ +"""Tests for $lookup correlated subquery — concise correlated syntax (additional cases). + +Extends test_lookup_concise_correlated_subquery.py with additional concise +syntax cases: empty pipeline equivalence, no-match behavior, and array +localField matching. Basic equality+pipeline and let+equality combinations +are covered in the pre-existing file. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.stages.lookup.utils.lookup_common import ( + FOREIGN, + LookupTestCase, + build_lookup_command, + setup_lookup, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +LOOKUP_CONCISE_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "concise_empty_pipeline_same_as_simple_equality", + docs=[{"_id": 1, "x": "val"}], + foreign_docs=[ + {"_id": 10, "y": "val"}, + {"_id": 11, "y": "other"}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "localField": "x", + "foreignField": "y", + "pipeline": [], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "x": "val", + "joined": [{"_id": 10, "y": "val"}], + } + ], + msg=( + "$lookup concise syntax with empty pipeline should be" + " equivalent to simple equality lookup" + ), + ), + LookupTestCase( + "concise_equality_no_match_pipeline_not_executed", + docs=[{"_id": 1, "x": "no_match"}], + foreign_docs=[ + {"_id": 10, "y": "val1"}, + {"_id": 11, "y": "val2"}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "localField": "x", + "foreignField": "y", + "let": {"v": "$x"}, + "pipeline": [{"$addFields": {"src": "$$v"}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "x": "no_match", + "joined": [], + } + ], + msg=( + "$lookup concise syntax with no equality matches should" + " result in empty array (pipeline never runs on empty set)" + ), + ), + LookupTestCase( + "concise_localField_array_matches_scalar", + docs=[{"_id": 1, "refs": [10, 20]}], + foreign_docs=[ + {"_id": 10, "val": "a"}, + {"_id": 20, "val": "b"}, + {"_id": 30, "val": "c"}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "localField": "refs", + "foreignField": "_id", + "pipeline": [], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "refs": [10, 20], + "joined": [ + {"_id": 10, "val": "a"}, + {"_id": 20, "val": "b"}, + ], + } + ], + msg=( + "$lookup concise syntax with array localField should match" + " each element against scalar foreignField" + ), + ), +] + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(LOOKUP_CONCISE_TESTS)) +def test_lookup_correlated_concise(collection, test_case: LookupTestCase): + """Test $lookup concise correlated subquery syntax (localField/foreignField + pipeline).""" + with setup_lookup(collection, test_case) as foreign_name: + command = build_lookup_command(collection, test_case, foreign_name) + result = execute_command(collection, command) + 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/lookup/correlated/test_lookup_correlated_edge_cases.py b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_edge_cases.py new file mode 100644 index 000000000..95940c23d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_edge_cases.py @@ -0,0 +1,356 @@ +"""Tests for $lookup correlated subquery — edge cases, errors, and boundary behavior. + +Covers let expression errors, undefined variable references, naming edge cases, +numeric equivalence, special numeric values, $$REMOVE semantics, and +let variable path traversal. +""" + +from __future__ import annotations + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.stages.lookup.utils.lookup_common import ( + FOREIGN, + LookupTestCase, + build_lookup_command, + setup_lookup, +) +from documentdb_tests.framework.assertions import assertResult, assertSuccessNaN +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 + +# --- Section 1: Let Expression Errors --- + +LOOKUP_EDGE_ERROR_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "let_expr_type_mismatch_error", + docs=[{"_id": 1, "num": 5}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"bad": {"$add": ["$num", "not_a_number"]}}, + "pipeline": [], + "as": "joined", + } + } + ], + error_code=14, # TYPE_MISMATCH_ERROR + msg="$lookup let with $add type mismatch should propagate error", + ), + LookupTestCase( + "let_expr_error_for_some_docs_fails_all", + docs=[ + {"_id": 1, "x": 10}, + {"_id": 2, "x": 0}, + ], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"inv": {"$divide": [1, "$x"]}}, + "pipeline": [{"$addFields": {"val": "$$inv"}}], + "as": "joined", + } + } + ], + error_code=BAD_VALUE_ERROR, + msg=( + "$lookup let expression that errors for one outer doc" + " should fail the entire aggregate" + ), + ), +] + +# --- Section 2: Undefined Variable References --- + +LOOKUP_EDGE_UNDEFINED_VAR_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "undefined_var_in_addFields_errors", + docs=[{"_id": 1}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "$_id"}, + "pipeline": [{"$addFields": {"val": "$$undefined_var"}}], + "as": "joined", + } + } + ], + error_code=17276, + msg="$lookup referencing undefined variable in $addFields should error", + ), + LookupTestCase( + "empty_let_then_reference_var_errors", + docs=[{"_id": 1}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {}, + "pipeline": [{"$addFields": {"val": "$$anyVar"}}], + "as": "joined", + } + } + ], + error_code=17276, + msg="$lookup with empty let then referencing $$anyVar should error", + ), +] + +# --- Section 3: Numeric Equivalence in $expr Comparisons --- + +LOOKUP_EDGE_NUMERIC_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "numeric_equivalence_int_matches_double", + docs=[{"_id": 1, "val": 5}], + foreign_docs=[ + {"_id": 10, "field": 5.0}, + {"_id": 11, "field": 6.0}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "$val"}, + "pipeline": [{"$match": {"$expr": {"$eq": ["$field", "$$x"]}}}], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "val": 5, "joined": [{"_id": 10, "field": 5.0}]}], + msg="$lookup $expr $eq with int let var should match double of same numeric value", + ), + LookupTestCase( + "numeric_equivalence_long_matches_decimal128", + docs=[{"_id": 1, "val": Int64(5)}], + foreign_docs=[ + {"_id": 10, "field": Decimal128("5")}, + {"_id": 11, "field": Decimal128("6")}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "$val"}, + "pipeline": [{"$match": {"$expr": {"$eq": ["$field", "$$x"]}}}], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "val": Int64(5), "joined": [{"_id": 10, "field": Decimal128("5")}]}], + msg="$lookup $expr $eq with Int64 let var should match Decimal128 of same value", + ), + LookupTestCase( + "type_distinction_int_does_not_match_string", + docs=[{"_id": 1, "val": 5}], + foreign_docs=[ + {"_id": 10, "field": "5"}, + {"_id": 11, "field": 5}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "$val"}, + "pipeline": [{"$match": {"$expr": {"$eq": ["$field", "$$x"]}}}], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "val": 5, "joined": [{"_id": 11, "field": 5}]}], + msg="$lookup $expr $eq with int let var should NOT match string of same characters", + ), +] + +# --- Section 4: Special Numeric Values --- + +LOOKUP_EDGE_SPECIAL_VALUES_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "negative_zero_matches_positive_zero", + docs=[{"_id": 1, "val": -0.0}], + foreign_docs=[ + {"_id": 10, "field": 0.0}, + {"_id": 11, "field": 1.0}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "$val"}, + "pipeline": [{"$match": {"$expr": {"$eq": ["$field", "$$x"]}}}], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "val": -0.0, "joined": [{"_id": 10, "field": 0.0}]}], + msg="$lookup $expr $eq with -0.0 let var should match 0.0 (IEEE 754 equality)", + ), +] + +# --- Section 5: $$REMOVE Semantics --- + +LOOKUP_EDGE_REMOVE_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "remove_in_addFields_omits_field", + docs=[{"_id": 1}], + foreign_docs=[{"_id": 10, "keep": "yes"}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"removed": "$$REMOVE"}, + "pipeline": [{"$addFields": {"x": "$$removed", "y": "present"}}], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "joined": [{"_id": 10, "keep": "yes", "y": "present"}]}], + msg=( + "$lookup let with $$REMOVE value should omit the field" + " when used in $addFields (field not present in output)" + ), + ), + LookupTestCase( + "remove_type_is_missing", + docs=[{"_id": 1}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"removed": "$$REMOVE"}, + "pipeline": [{"$addFields": {"t": {"$type": "$$removed"}}}], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "joined": [{"_id": 10, "t": "missing"}]}], + msg="$lookup let with $$REMOVE should report $type as 'missing'", + ), +] + +# --- Section 6: Let Variable Path Traversal --- + +LOOKUP_EDGE_PATH_TRAVERSAL_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "path_traversal_on_let_var_object", + docs=[{"_id": 1, "obj": {"nested": {"field": "deep_value"}}}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"doc": "$obj"}, + "pipeline": [{"$addFields": {"val": "$$doc.nested.field"}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "obj": {"nested": {"field": "deep_value"}}, + "joined": [{"_id": 10, "val": "deep_value"}], + } + ], + msg="$lookup let var path traversal '$$doc.nested.field' should resolve sub-path", + ), + LookupTestCase( + "path_traversal_on_let_var_nonexistent_path", + docs=[{"_id": 1, "obj": {"a": 1}}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"doc": "$obj"}, + "pipeline": [ + { + "$addFields": { + "val": "$$doc.missing.path", + "t": {"$type": "$$doc.missing.path"}, + } + } + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "obj": {"a": 1}, + "joined": [{"_id": 10, "t": "missing"}], + } + ], + msg=( + "$lookup let var path traversal to non-existent sub-path" + " should resolve to missing (field omitted from output)" + ), + ), +] + + +# --- Combine all tests --- +LOOKUP_CORRELATED_EDGE_CASES_ALL: list[LookupTestCase] = ( + LOOKUP_EDGE_ERROR_TESTS + + LOOKUP_EDGE_UNDEFINED_VAR_TESTS + + LOOKUP_EDGE_NUMERIC_TESTS + + LOOKUP_EDGE_SPECIAL_VALUES_TESTS + + LOOKUP_EDGE_REMOVE_TESTS + + LOOKUP_EDGE_PATH_TRAVERSAL_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(LOOKUP_CORRELATED_EDGE_CASES_ALL)) +def test_lookup_correlated_edge_cases(collection, test_case: LookupTestCase): + """Test $lookup correlated subquery edge cases, errors, and boundary behavior.""" + with setup_lookup(collection, test_case) as foreign_name: + command = build_lookup_command(collection, test_case, foreign_name) + result = execute_command(collection, command) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) + + +_NAN_TEST = LookupTestCase( + "nan_matches_nan_in_expr_eq", + docs=[{"_id": 1, "val": float("nan")}], + foreign_docs=[ + {"_id": 10, "field": float("nan")}, + {"_id": 11, "field": 0}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "$val"}, + "pipeline": [{"$match": {"$expr": {"$eq": ["$field", "$$x"]}}}], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "val": float("nan"), "joined": [{"_id": 10, "field": float("nan")}]}], + msg="$lookup $expr $eq with NaN let var matches NaN foreign field (NaN == NaN in $expr)", +) + + +@pytest.mark.aggregate +def test_lookup_correlated_nan_matches_nan(collection): + """Test $lookup $expr $eq with NaN let var matches NaN (uses NaN-aware assertion).""" + with setup_lookup(collection, _NAN_TEST) as foreign_name: + command = build_lookup_command(collection, _NAN_TEST, foreign_name) + result = execute_command(collection, command) + assertSuccessNaN(result, _NAN_TEST.expected, msg=_NAN_TEST.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_let_forms.py b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_let_forms.py new file mode 100644 index 000000000..84496759d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_let_forms.py @@ -0,0 +1,760 @@ +"""Tests for $lookup correlated subquery — let variable forms and type preservation. + +Covers comprehensive let variable value forms beyond what exists in +test_lookup_correlated_subquery.py: additional BSON types (ObjectId, Decimal128, +BinData, Timestamp, Regex, MinKey, MaxKey), field path traversal patterns, +complex aggregation expressions, and mixed multi-variable let specifications. +""" + +from __future__ import annotations + +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.stages.lookup.utils.lookup_common import ( + FOREIGN, + LookupTestCase, + build_lookup_command, + setup_lookup, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# --- Section 1: Constant Literals as Let Values (additional BSON types) --- +# Existing coverage: int, string, bool, double, null, array, document. +# New coverage: Int64, Decimal128, ObjectId, Date, BinData, Timestamp, Regex, MinKey, MaxKey. + +_OID = ObjectId("aaaaaaaaaaaaaaaaaaaaaaaa") +_DATE = datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc) +_BIN = Binary(b"\x01\x02\x03") +_TS = Timestamp(1700000000, 1) +_REGEX = Regex("^hello", "i") + +LOOKUP_LET_CONSTANT_LITERAL_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "let_constant_int64", + docs=[{"_id": 1}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": Int64(9999999999)}, + "pipeline": [{"$addFields": {"val": "$$x", "t": {"$type": "$$x"}}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "joined": [{"_id": 10, "val": Int64(9999999999), "t": "long"}], + } + ], + msg="$lookup let with Int64 constant should preserve long type", + ), + LookupTestCase( + "let_constant_decimal128", + docs=[{"_id": 1}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": Decimal128("123.456")}, + "pipeline": [{"$addFields": {"val": "$$x", "t": {"$type": "$$x"}}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "joined": [{"_id": 10, "val": Decimal128("123.456"), "t": "decimal"}], + } + ], + msg="$lookup let with Decimal128 constant should preserve decimal type", + ), + LookupTestCase( + "let_constant_objectid", + docs=[{"_id": 1}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": _OID}, + "pipeline": [{"$addFields": {"val": "$$x", "t": {"$type": "$$x"}}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "joined": [{"_id": 10, "val": _OID, "t": "objectId"}], + } + ], + msg="$lookup let with ObjectId constant should preserve objectId type", + ), + LookupTestCase( + "let_constant_date", + docs=[{"_id": 1}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": _DATE}, + "pipeline": [{"$addFields": {"val": "$$x", "t": {"$type": "$$x"}}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "joined": [{"_id": 10, "val": _DATE, "t": "date"}], + } + ], + msg="$lookup let with ISODate constant should preserve date type", + ), + LookupTestCase( + "let_constant_binary", + docs=[{"_id": 1}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": _BIN}, + "pipeline": [{"$addFields": {"val": "$$x", "t": {"$type": "$$x"}}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "joined": [{"_id": 10, "val": b"\x01\x02\x03", "t": "binData"}], + } + ], + msg="$lookup let with BinData constant should preserve binData type", + ), + LookupTestCase( + "let_constant_timestamp", + docs=[{"_id": 1}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": _TS}, + "pipeline": [{"$addFields": {"val": "$$x", "t": {"$type": "$$x"}}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "joined": [{"_id": 10, "val": _TS, "t": "timestamp"}], + } + ], + msg="$lookup let with Timestamp constant should preserve timestamp type", + ), + LookupTestCase( + "let_constant_regex", + docs=[{"_id": 1}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": _REGEX}, + "pipeline": [{"$addFields": {"t": {"$type": "$$x"}}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "joined": [{"_id": 10, "t": "regex"}], + } + ], + msg="$lookup let with Regex constant should preserve regex type", + ), + LookupTestCase( + "let_constant_minkey", + docs=[{"_id": 1}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": MinKey()}, + "pipeline": [{"$addFields": {"t": {"$type": "$$x"}}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "joined": [{"_id": 10, "t": "minKey"}], + } + ], + msg="$lookup let with MinKey constant should preserve minKey type", + ), + LookupTestCase( + "let_constant_maxkey", + docs=[{"_id": 1}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": MaxKey()}, + "pipeline": [{"$addFields": {"t": {"$type": "$$x"}}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "joined": [{"_id": 10, "t": "maxKey"}], + } + ], + msg="$lookup let with MaxKey constant should preserve maxKey type", + ), +] + +# --- Section 2: Field References as Let Values (advanced traversal) --- +# Existing coverage: simple "$field", "$missing" +# New coverage: nested paths, deep nesting, array field, dotted path through array-of-objects + +LOOKUP_LET_FIELD_REFERENCE_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "let_field_ref_nested_path", + docs=[{"_id": 1, "nested": {"field": 42}}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "$nested.field"}, + "pipeline": [{"$addFields": {"val": "$$x"}}], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "nested": {"field": 42}, "joined": [{"_id": 10, "val": 42}]}], + msg="$lookup let with nested field path should resolve nested document field", + ), + LookupTestCase( + "let_field_ref_deeply_nested", + docs=[{"_id": 1, "a": {"b": {"c": {"d": "deep"}}}}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "$a.b.c.d"}, + "pipeline": [{"$addFields": {"val": "$$x"}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "a": {"b": {"c": {"d": "deep"}}}, + "joined": [{"_id": 10, "val": "deep"}], + } + ], + msg="$lookup let with deeply nested field path should resolve through multiple levels", + ), + LookupTestCase( + "let_field_ref_array_field", + docs=[{"_id": 1, "arr": [10, 20, 30]}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "$arr"}, + "pipeline": [{"$addFields": {"val": "$$x"}}], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "arr": [10, 20, 30], "joined": [{"_id": 10, "val": [10, 20, 30]}]}], + msg="$lookup let with field path to array should pass entire array as variable value", + ), + LookupTestCase( + "let_field_ref_dotted_through_array_of_objects", + docs=[{"_id": 1, "items": [{"name": "a"}, {"name": "b"}]}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "$items.name"}, + "pipeline": [{"$addFields": {"val": "$$x"}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "items": [{"name": "a"}, {"name": "b"}], + "joined": [{"_id": 10, "val": ["a", "b"]}], + } + ], + msg=( + "$lookup let with dotted path through array-of-objects should" + " resolve to array of matched values" + ), + ), + LookupTestCase( + "let_field_ref_null_field", + docs=[{"_id": 1, "nullField": None}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "$nullField"}, + "pipeline": [{"$addFields": {"val": "$$x", "t": {"$type": "$$x"}}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "nullField": None, + "joined": [{"_id": 10, "val": None, "t": "null"}], + } + ], + msg="$lookup let with field path to null field should resolve to null (not missing)", + ), + LookupTestCase( + "let_field_ref_arrayElemAt_expression", + docs=[{"_id": 1, "arr": [10, 20, 30]}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": {"$arrayElemAt": ["$arr", 0]}}, + "pipeline": [{"$addFields": {"val": "$$x"}}], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "arr": [10, 20, 30], "joined": [{"_id": 10, "val": 10}]}], + msg="$lookup let with $arrayElemAt expression should extract single array element", + ), +] + +# --- Section 3: Aggregation Expressions as Let Values (beyond basic $add/$concat) --- +# Existing coverage: $add, $concat +# New coverage: $cond, $ifNull, $size, $toUpper, nested expressions, $type, $dateToString + +LOOKUP_LET_EXPRESSION_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "let_expr_cond_true_branch", + docs=[{"_id": 1, "val": 10}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": {"$cond": [{"$gt": ["$val", 5]}, "high", "low"]}}, + "pipeline": [{"$addFields": {"label": "$$x"}}], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "val": 10, "joined": [{"_id": 10, "label": "high"}]}], + msg="$lookup let with $cond expression should evaluate true branch correctly", + ), + LookupTestCase( + "let_expr_cond_false_branch", + docs=[{"_id": 1, "val": 2}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": {"$cond": [{"$gt": ["$val", 5]}, "high", "low"]}}, + "pipeline": [{"$addFields": {"label": "$$x"}}], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "val": 2, "joined": [{"_id": 10, "label": "low"}]}], + msg="$lookup let with $cond expression should evaluate false branch correctly", + ), + LookupTestCase( + "let_expr_ifNull_with_null_field", + docs=[{"_id": 1, "maybe": None}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": {"$ifNull": ["$maybe", "default"]}}, + "pipeline": [{"$addFields": {"val": "$$x"}}], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "maybe": None, "joined": [{"_id": 10, "val": "default"}]}], + msg="$lookup let with $ifNull should use default when field is null", + ), + LookupTestCase( + "let_expr_size", + docs=[{"_id": 1, "arr": [1, 2, 3]}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": {"$size": "$arr"}}, + "pipeline": [{"$addFields": {"count": "$$x"}}], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "arr": [1, 2, 3], "joined": [{"_id": 10, "count": 3}]}], + msg="$lookup let with $size expression should compute array length", + ), + LookupTestCase( + "let_expr_toUpper", + docs=[{"_id": 1, "name": "hello"}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": {"$toUpper": "$name"}}, + "pipeline": [{"$addFields": {"upper": "$$x"}}], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "name": "hello", "joined": [{"_id": 10, "upper": "HELLO"}]}], + msg="$lookup let with $toUpper expression should transform string to uppercase", + ), + LookupTestCase( + "let_expr_nested_arithmetic", + docs=[{"_id": 1, "a": 3, "b": 1}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": {"$add": [{"$multiply": ["$a", 2]}, "$b"]}}, + "pipeline": [{"$addFields": {"val": "$$x"}}], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "a": 3, "b": 1, "joined": [{"_id": 10, "val": 7}]}], + msg="$lookup let with nested arithmetic expression should compute correctly", + ), + LookupTestCase( + "let_expr_type_operator", + docs=[{"_id": 1, "field": 42}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": {"$type": "$field"}}, + "pipeline": [{"$addFields": {"typename": "$$x"}}], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "field": 42, "joined": [{"_id": 10, "typename": "int"}]}], + msg="$lookup let with $type expression should produce type name string", + ), + LookupTestCase( + "let_expr_dateToString", + docs=[{"_id": 1, "d": _DATE}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": {"$dateToString": {"format": "%Y", "date": "$d"}}}, + "pipeline": [{"$addFields": {"year": "$$x"}}], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "d": _DATE, "joined": [{"_id": 10, "year": "2024"}]}], + msg="$lookup let with $dateToString expression should format date", + ), +] + +# --- Section 4: Mixed Forms (multiple variables of different kinds) --- +# Existing coverage: multiple BSON types in one let, expression + field ref +# New coverage: constant + field ref + expression combo, system var + field ref, +# multiple expressions referencing same source field + +LOOKUP_LET_MIXED_FORMS_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "let_mixed_constant_field_expression", + docs=[{"_id": 1, "score": 85}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": { + "threshold": 70, + "val": "$score", + "doubled": {"$multiply": ["$score", 2]}, + }, + "pipeline": [ + { + "$addFields": { + "t": "$$threshold", + "v": "$$val", + "d": "$$doubled", + } + } + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "score": 85, + "joined": [{"_id": 10, "t": 70, "v": 85, "d": 170}], + } + ], + msg=( + "$lookup let with constant, field ref, and expression should" + " resolve all three independently" + ), + ), + LookupTestCase( + "let_mixed_system_var_and_field_ref", + docs=[{"_id": 1, "specific": "value"}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"doc": "$$ROOT", "field_val": "$specific"}, + "pipeline": [ + { + "$addFields": { + "full_doc": "$$doc", + "single_field": "$$field_val", + } + } + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "specific": "value", + "joined": [ + { + "_id": 10, + "full_doc": {"_id": 1, "specific": "value"}, + "single_field": "value", + } + ], + } + ], + msg=( + "$lookup let with $$ROOT and field ref should resolve" + " system variable and field path independently" + ), + ), + LookupTestCase( + "let_mixed_multiple_expressions_same_source", + docs=[{"_id": 1, "x": 10}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": { + "doubled": {"$multiply": ["$x", 2]}, + "halved": {"$divide": ["$x", 2]}, + "original": "$x", + }, + "pipeline": [ + { + "$addFields": { + "d": "$$doubled", + "h": "$$halved", + "o": "$$original", + } + } + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "x": 10, + "joined": [{"_id": 10, "d": 20, "h": 5.0, "o": 10}], + } + ], + msg=( + "$lookup let with multiple expressions referencing same source" + " field should evaluate each independently" + ), + ), + LookupTestCase( + "let_many_variables", + docs=[{"_id": 1, "a": 1, "b": 2, "c": 3, "d": 4, "e": 5}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": { + "v1": "$a", + "v2": "$b", + "v3": "$c", + "v4": "$d", + "v5": "$e", + "s1": {"$add": ["$a", "$b"]}, + "s2": {"$multiply": ["$c", "$d"]}, + "const": "static", + }, + "pipeline": [ + { + "$addFields": { + "r1": "$$v1", + "r2": "$$v2", + "r3": "$$v3", + "r4": "$$v4", + "r5": "$$v5", + "rs1": "$$s1", + "rs2": "$$s2", + "rc": "$$const", + } + } + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "a": 1, + "b": 2, + "c": 3, + "d": 4, + "e": 5, + "joined": [ + { + "_id": 10, + "r1": 1, + "r2": 2, + "r3": 3, + "r4": 4, + "r5": 5, + "rs1": 3, + "rs2": 12, + "rc": "static", + } + ], + } + ], + msg="$lookup let with many variables (8) should all be accessible in sub-pipeline", + ), + LookupTestCase( + "let_variable_with_underscore_name", + docs=[{"_id": 1, "x": "val"}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"my_var": "$x"}, + "pipeline": [{"$addFields": {"result": "$$my_var"}}], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "x": "val", "joined": [{"_id": 10, "result": "val"}]}], + msg="$lookup let variable with underscore in name should be valid and accessible", + ), + LookupTestCase( + "let_variable_name_is_prefix_of_another", + docs=[{"_id": 1, "a": "first", "b": "second"}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"item": "$a", "itemId": "$b"}, + "pipeline": [{"$addFields": {"r1": "$$item", "r2": "$$itemId"}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "a": "first", + "b": "second", + "joined": [{"_id": 10, "r1": "first", "r2": "second"}], + } + ], + msg=( + "$lookup let with variable name that is prefix of another" + " should resolve both without ambiguity" + ), + ), +] + + +# --- Combine all tests --- +LOOKUP_CORRELATED_LET_FORMS_ALL: list[LookupTestCase] = ( + LOOKUP_LET_CONSTANT_LITERAL_TESTS + + LOOKUP_LET_FIELD_REFERENCE_TESTS + + LOOKUP_LET_EXPRESSION_TESTS + + LOOKUP_LET_MIXED_FORMS_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(LOOKUP_CORRELATED_LET_FORMS_ALL)) +def test_lookup_correlated_let_forms(collection, test_case: LookupTestCase): + """Test $lookup correlated subquery let variable forms and type preservation.""" + with setup_lookup(collection, test_case) as foreign_name: + command = build_lookup_command(collection, test_case, foreign_name) + result = execute_command(collection, command) + 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/lookup/correlated/test_lookup_correlated_match.py b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_match.py new file mode 100644 index 000000000..5d4ccd3c9 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_match.py @@ -0,0 +1,872 @@ +"""Tests for $lookup correlated subquery — $match with $expr using let variables. + +Covers the primary use case for correlated $lookup: filtering foreign documents +using let variables inside $match with $expr. Includes comparison operators, +logical operators, arithmetic, string/array operations, date comparisons, +null/missing handling, and literal behavior without $expr. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.stages.lookup.utils.lookup_common import ( + FOREIGN, + LookupTestCase, + build_lookup_command, + setup_lookup, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# --- Section 1: Comparison Operators with Let Variables in $expr --- + +LOOKUP_MATCH_COMPARISON_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "match_expr_eq", + docs=[{"_id": 1, "cat": "electronics"}], + foreign_docs=[ + {"_id": 10, "category": "electronics"}, + {"_id": 11, "category": "clothing"}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"localCat": "$cat"}, + "pipeline": [{"$match": {"$expr": {"$eq": ["$category", "$$localCat"]}}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "cat": "electronics", + "joined": [{"_id": 10, "category": "electronics"}], + } + ], + msg="$lookup $match $expr $eq should match foreign docs where field equals let var", + ), + LookupTestCase( + "match_expr_ne", + docs=[{"_id": 1, "exclude": "inactive"}], + foreign_docs=[ + {"_id": 10, "status": "active"}, + {"_id": 11, "status": "inactive"}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"excl": "$exclude"}, + "pipeline": [{"$match": {"$expr": {"$ne": ["$status", "$$excl"]}}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "exclude": "inactive", + "joined": [{"_id": 10, "status": "active"}], + } + ], + msg="$lookup $match $expr $ne should match foreign docs where field differs from let var", + ), + LookupTestCase( + "match_expr_gt", + docs=[{"_id": 1, "minScore": 70}], + foreign_docs=[ + {"_id": 10, "score": 80}, + {"_id": 11, "score": 60}, + {"_id": 12, "score": 70}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"min": "$minScore"}, + "pipeline": [{"$match": {"$expr": {"$gt": ["$score", "$$min"]}}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "minScore": 70, + "joined": [{"_id": 10, "score": 80}], + } + ], + msg="$lookup $match $expr $gt should match foreign docs where field > let var", + ), + LookupTestCase( + "match_expr_gte", + docs=[{"_id": 1, "minScore": 70}], + foreign_docs=[ + {"_id": 10, "score": 80}, + {"_id": 11, "score": 60}, + {"_id": 12, "score": 70}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"min": "$minScore"}, + "pipeline": [{"$match": {"$expr": {"$gte": ["$score", "$$min"]}}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "minScore": 70, + "joined": [{"_id": 10, "score": 80}, {"_id": 12, "score": 70}], + } + ], + msg="$lookup $match $expr $gte should match foreign docs where field >= let var", + ), + LookupTestCase( + "match_expr_lt", + docs=[{"_id": 1, "maxPrice": 50}], + foreign_docs=[ + {"_id": 10, "price": 30}, + {"_id": 11, "price": 70}, + {"_id": 12, "price": 50}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"maxP": "$maxPrice"}, + "pipeline": [{"$match": {"$expr": {"$lt": ["$price", "$$maxP"]}}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "maxPrice": 50, + "joined": [{"_id": 10, "price": 30}], + } + ], + msg="$lookup $match $expr $lt should match foreign docs where field < let var", + ), + LookupTestCase( + "match_expr_lte", + docs=[{"_id": 1, "maxPrice": 50}], + foreign_docs=[ + {"_id": 10, "price": 30}, + {"_id": 11, "price": 70}, + {"_id": 12, "price": 50}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"maxP": "$maxPrice"}, + "pipeline": [{"$match": {"$expr": {"$lte": ["$price", "$$maxP"]}}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "maxPrice": 50, + "joined": [{"_id": 10, "price": 30}, {"_id": 12, "price": 50}], + } + ], + msg="$lookup $match $expr $lte should match foreign docs where field <= let var", + ), +] + +# --- Section 2: Logical Operators Combining Let Variables --- + +LOOKUP_MATCH_LOGICAL_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "match_expr_and_two_let_vars", + docs=[{"_id": 1, "wantType": "A", "minScore": 50}], + foreign_docs=[ + {"_id": 10, "type": "A", "score": 80}, + {"_id": 11, "type": "A", "score": 30}, + {"_id": 12, "type": "B", "score": 90}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"t": "$wantType", "min": "$minScore"}, + "pipeline": [ + { + "$match": { + "$expr": { + "$and": [ + {"$eq": ["$type", "$$t"]}, + {"$gte": ["$score", "$$min"]}, + ] + } + } + } + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "wantType": "A", + "minScore": 50, + "joined": [{"_id": 10, "type": "A", "score": 80}], + } + ], + msg="$lookup $match $expr $and should apply both let var conditions", + ), + LookupTestCase( + "match_expr_or_two_let_vars", + docs=[{"_id": 1, "cat1": "electronics", "cat2": "books"}], + foreign_docs=[ + {"_id": 10, "category": "electronics"}, + {"_id": 11, "category": "clothing"}, + {"_id": 12, "category": "books"}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"c1": "$cat1", "c2": "$cat2"}, + "pipeline": [ + { + "$match": { + "$expr": { + "$or": [ + {"$eq": ["$category", "$$c1"]}, + {"$eq": ["$category", "$$c2"]}, + ] + } + } + } + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "cat1": "electronics", + "cat2": "books", + "joined": [ + {"_id": 10, "category": "electronics"}, + {"_id": 12, "category": "books"}, + ], + } + ], + msg="$lookup $match $expr $or should match when either let var condition is met", + ), + LookupTestCase( + "match_expr_not_with_let_var", + docs=[{"_id": 1, "excludeStatus": "deleted"}], + foreign_docs=[ + {"_id": 10, "status": "active"}, + {"_id": 11, "status": "deleted"}, + {"_id": 12, "status": "pending"}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"excl": "$excludeStatus"}, + "pipeline": [{"$match": {"$expr": {"$not": [{"$eq": ["$status", "$$excl"]}]}}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "excludeStatus": "deleted", + "joined": [ + {"_id": 10, "status": "active"}, + {"_id": 12, "status": "pending"}, + ], + } + ], + msg="$lookup $match $expr $not should exclude docs matching the let var", + ), +] + +# --- Section 3: Arithmetic in $expr with Let Variables --- + +LOOKUP_MATCH_ARITHMETIC_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "match_expr_multiply_let_var", + docs=[{"_id": 1, "limit": 25}], + foreign_docs=[ + {"_id": 10, "amount": 60}, + {"_id": 11, "amount": 40}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"lim": "$limit"}, + "pipeline": [ + {"$match": {"$expr": {"$gt": ["$amount", {"$multiply": ["$$lim", 2]}]}}} + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "limit": 25, + "joined": [{"_id": 10, "amount": 60}], + } + ], + msg="$lookup $match $expr with $multiply on let var should compute threshold correctly", + ), + LookupTestCase( + "match_expr_add_let_var", + docs=[{"_id": 1, "base": 40}], + foreign_docs=[ + {"_id": 10, "price": 45}, + {"_id": 11, "price": 55}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"b": "$base"}, + "pipeline": [{"$match": {"$expr": {"$lt": ["$price", {"$add": ["$$b", 10]}]}}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "base": 40, + "joined": [{"_id": 10, "price": 45}], + } + ], + msg="$lookup $match $expr with $add on let var should compute threshold correctly", + ), + LookupTestCase( + "match_expr_subtract_let_var", + docs=[{"_id": 1, "target": 100}], + foreign_docs=[ + {"_id": 10, "qty": 90}, + {"_id": 11, "qty": 96}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"tgt": "$target"}, + "pipeline": [ + {"$match": {"$expr": {"$gte": ["$qty", {"$subtract": ["$$tgt", 5]}]}}} + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "target": 100, + "joined": [{"_id": 11, "qty": 96}], + } + ], + msg="$lookup $match $expr with $subtract on let var should compute threshold correctly", + ), +] + +# --- Section 4: Array Operations in $expr with Let Variables --- + +LOOKUP_MATCH_ARRAY_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "match_expr_in_let_var_is_element", + docs=[{"_id": 1, "wantedTag": "python"}], + foreign_docs=[ + {"_id": 10, "tags": ["python", "java"]}, + {"_id": 11, "tags": ["rust", "go"]}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"tag": "$wantedTag"}, + "pipeline": [{"$match": {"$expr": {"$in": ["$$tag", "$tags"]}}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "wantedTag": "python", + "joined": [{"_id": 10, "tags": ["python", "java"]}], + } + ], + msg="$lookup $match $expr $in with let var as element should find it in foreign array", + ), + LookupTestCase( + "match_expr_in_let_var_is_array", + docs=[{"_id": 1, "allowed": ["python", "rust"]}], + foreign_docs=[ + {"_id": 10, "lang": "python"}, + {"_id": 11, "lang": "java"}, + {"_id": 12, "lang": "rust"}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"allowedTags": "$allowed"}, + "pipeline": [{"$match": {"$expr": {"$in": ["$lang", "$$allowedTags"]}}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "allowed": ["python", "rust"], + "joined": [ + {"_id": 10, "lang": "python"}, + {"_id": 12, "lang": "rust"}, + ], + } + ], + msg="$lookup $match $expr $in with let var as array should match foreign elements in it", + ), + LookupTestCase( + "match_expr_setIntersection_overlap", + docs=[{"_id": 1, "wanted": ["A", "B"]}], + foreign_docs=[ + {"_id": 10, "cats": ["A", "C"]}, + {"_id": 11, "cats": ["D", "E"]}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"w": "$wanted"}, + "pipeline": [ + { + "$match": { + "$expr": { + "$gt": [ + {"$size": {"$setIntersection": ["$cats", "$$w"]}}, + 0, + ] + } + } + } + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "wanted": ["A", "B"], + "joined": [{"_id": 10, "cats": ["A", "C"]}], + } + ], + msg="$lookup $match $expr with $setIntersection should find foreign docs with overlap", + ), +] + +# --- Section 5: Null/Missing Handling in $match $expr --- + +LOOKUP_MATCH_NULL_MISSING_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "match_expr_eq_let_var_null_matches_null", + docs=[{"_id": 1, "val": None}], + foreign_docs=[ + {"_id": 10, "field": None}, + {"_id": 11, "field": "something"}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "$val"}, + "pipeline": [{"$match": {"$expr": {"$eq": ["$field", "$$x"]}}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "val": None, + "joined": [{"_id": 10, "field": None}], + } + ], + msg="$lookup $match $expr $eq with null let var should match foreign null fields", + ), + LookupTestCase( + "match_expr_eq_let_var_missing_vs_null", + docs=[{"_id": 1}], + foreign_docs=[ + {"_id": 10, "field": None}, + {"_id": 11, "field": "value"}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "$nonexistent"}, + "pipeline": [{"$match": {"$expr": {"$eq": ["$field", "$$x"]}}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "joined": [], + } + ], + msg=( + "$lookup $match $expr $eq with missing let var should NOT" + " match foreign null fields (missing != null in $expr $eq)" + ), + ), + LookupTestCase( + "match_expr_ifNull_wrapping_let_var", + docs=[{"_id": 1, "maybe": None}], + foreign_docs=[ + {"_id": 10, "field": "fallback"}, + {"_id": 11, "field": "other"}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "$maybe"}, + "pipeline": [ + {"$match": {"$expr": {"$eq": ["$field", {"$ifNull": ["$$x", "fallback"]}]}}} + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "maybe": None, + "joined": [{"_id": 10, "field": "fallback"}], + } + ], + msg="$lookup $match $expr with $ifNull wrapping null let var should use default value", + ), +] + +# --- Section 6: $match Without $expr (literal string behavior) --- + +LOOKUP_MATCH_WITHOUT_EXPR_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "match_without_expr_gt_treats_var_as_string", + docs=[{"_id": 1, "minAge": 25}], + foreign_docs=[ + {"_id": 10, "age": 30}, + {"_id": 11, "age": "$$minAge"}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"minAge": "$minAge"}, + "pipeline": [{"$match": {"age": {"$gt": "$$minAge"}}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "minAge": 25, + "joined": [], + } + ], + msg=( + "$lookup $match without $expr should treat $$var as literal" + " string in $gt operator (no numeric comparison)" + ), + ), + LookupTestCase( + "match_mixing_plain_and_expr", + docs=[{"_id": 1, "localType": "premium"}], + foreign_docs=[ + {"_id": 10, "status": "active", "type": "premium"}, + {"_id": 11, "status": "active", "type": "basic"}, + {"_id": 12, "status": "inactive", "type": "premium"}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"t": "$localType"}, + "pipeline": [ + { + "$match": { + "status": "active", + "$expr": {"$eq": ["$type", "$$t"]}, + } + } + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "localType": "premium", + "joined": [{"_id": 10, "status": "active", "type": "premium"}], + } + ], + msg=( + "$lookup $match mixing plain query with $expr should apply" + " both conditions — plain filter and let-variable comparison" + ), + ), + LookupTestCase( + "match_plain_query_no_let_interaction", + docs=[{"_id": 1, "status": "archived"}], + foreign_docs=[ + {"_id": 10, "status": "active"}, + {"_id": 11, "status": "archived"}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"s": "$status"}, + "pipeline": [{"$match": {"status": "active"}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "status": "archived", + "joined": [{"_id": 10, "status": "active"}], + } + ], + msg=( + "$lookup $match with plain query should filter foreign docs" + " independently of let vars (matches foreign status, not outer)" + ), + ), +] + +# --- Section 7: Multiple $match Stages Using Let Variables --- + +LOOKUP_MATCH_MULTIPLE_STAGES_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "match_two_stages_cumulative_filter", + docs=[{"_id": 1, "minScore": 50, "maxPrice": 100}], + foreign_docs=[ + {"_id": 10, "score": 80, "price": 90}, + {"_id": 11, "score": 80, "price": 150}, + {"_id": 12, "score": 30, "price": 50}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"min": "$minScore", "maxP": "$maxPrice"}, + "pipeline": [ + {"$match": {"$expr": {"$gte": ["$score", "$$min"]}}}, + {"$match": {"$expr": {"$lte": ["$price", "$$maxP"]}}}, + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "minScore": 50, + "maxPrice": 100, + "joined": [{"_id": 10, "score": 80, "price": 90}], + } + ], + msg=( + "$lookup with two $match $expr stages should apply both" + " filters cumulatively using different let variables" + ), + ), + LookupTestCase( + "match_expr_then_plain_match", + docs=[{"_id": 1, "wantType": "A"}], + foreign_docs=[ + {"_id": 10, "type": "A", "active": True}, + {"_id": 11, "type": "A", "active": False}, + {"_id": 12, "type": "B", "active": True}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"t": "$wantType"}, + "pipeline": [ + {"$match": {"$expr": {"$eq": ["$type", "$$t"]}}}, + {"$match": {"active": True}}, + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "wantType": "A", + "joined": [{"_id": 10, "type": "A", "active": True}], + } + ], + msg=( + "$lookup with $match $expr followed by plain $match should" + " apply both correlated and non-correlated filters" + ), + ), +] + +# --- Section 8: Date Comparisons in $expr with Let Variables --- + +_DATE_CUTOFF = datetime(2024, 6, 1, tzinfo=timezone.utc) +_DATE_BEFORE = datetime(2024, 5, 15, tzinfo=timezone.utc) +_DATE_AFTER = datetime(2024, 7, 10, tzinfo=timezone.utc) + +LOOKUP_MATCH_DATE_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "match_expr_date_gt_let_var", + docs=[{"_id": 1, "cutoff": _DATE_CUTOFF}], + foreign_docs=[ + {"_id": 10, "eventDate": _DATE_AFTER}, + {"_id": 11, "eventDate": _DATE_BEFORE}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"cutoffDate": "$cutoff"}, + "pipeline": [{"$match": {"$expr": {"$gt": ["$eventDate", "$$cutoffDate"]}}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "cutoff": _DATE_CUTOFF, + "joined": [{"_id": 10, "eventDate": _DATE_AFTER}], + } + ], + msg="$lookup $match $expr $gt with date let var should find events after cutoff", + ), + LookupTestCase( + "match_expr_date_lt_let_var", + docs=[{"_id": 1, "deadline": _DATE_CUTOFF}], + foreign_docs=[ + {"_id": 10, "createdAt": _DATE_BEFORE}, + {"_id": 11, "createdAt": _DATE_AFTER}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"dl": "$deadline"}, + "pipeline": [{"$match": {"$expr": {"$lt": ["$createdAt", "$$dl"]}}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "deadline": _DATE_CUTOFF, + "joined": [{"_id": 10, "createdAt": _DATE_BEFORE}], + } + ], + msg="$lookup $match $expr $lt with date let var should find docs before deadline", + ), +] + +# --- Section 9: Pipeline Cannot Access Outer Fields Directly --- + +LOOKUP_MATCH_FIELD_ACCESS_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "match_plain_filter_resolves_against_foreign", + docs=[{"_id": 1, "status": "outer_active"}], + foreign_docs=[ + {"_id": 10, "status": "active"}, + {"_id": 11, "status": "inactive"}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "$status"}, + "pipeline": [{"$match": {"status": "active"}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "status": "outer_active", + "joined": [{"_id": 10, "status": "active"}], + } + ], + msg=( + "$lookup sub-pipeline $match without $expr resolves field" + " values against foreign collection, not outer document" + ), + ), +] + + +# --- Combine all tests --- +LOOKUP_CORRELATED_MATCH_ALL: list[LookupTestCase] = ( + LOOKUP_MATCH_COMPARISON_TESTS + + LOOKUP_MATCH_LOGICAL_TESTS + + LOOKUP_MATCH_ARITHMETIC_TESTS + + LOOKUP_MATCH_ARRAY_TESTS + + LOOKUP_MATCH_NULL_MISSING_TESTS + + LOOKUP_MATCH_WITHOUT_EXPR_TESTS + + LOOKUP_MATCH_MULTIPLE_STAGES_TESTS + + LOOKUP_MATCH_DATE_TESTS + + LOOKUP_MATCH_FIELD_ACCESS_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(LOOKUP_CORRELATED_MATCH_ALL)) +def test_lookup_correlated_match(collection, test_case: LookupTestCase): + """Test $lookup correlated subquery $match with $expr using let variables.""" + with setup_lookup(collection, test_case) as foreign_name: + command = build_lookup_command(collection, test_case, foreign_name) + result = execute_command(collection, command) + 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/lookup/correlated/test_lookup_correlated_nested.py b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_nested.py new file mode 100644 index 000000000..53ba5465d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_nested.py @@ -0,0 +1,648 @@ +"""Tests for $lookup correlated subquery — nested $lookup scoping and propagation. + +Covers let variable behavior across nested $lookup stages: multi-level +propagation, shadowing semantics, correlated $match in inner lookups, +concise syntax interaction, and structure verification. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.stages.lookup.utils.lookup_common import ( + FOREIGN, + LookupTestCase, + build_lookup_command, + setup_lookup, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# --- Section 1: Multi-Level Let Propagation --- +# Existing coverage: basic propagation (1 level), basic shadow (1 level) +# New coverage: 3-level propagation, outer+inner let coexistence, outer let in inner $match + +LOOKUP_NESTED_PROPAGATION_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "outer_let_accessible_in_doubly_nested", + docs=[{"_id": 1, "val": "from_outer"}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "$val"}, + "pipeline": [ + { + "$lookup": { + "from": FOREIGN, + "pipeline": [ + { + "$lookup": { + "from": FOREIGN, + "pipeline": [{"$addFields": {"deep": "$$x"}}], + "as": "level3", + } + } + ], + "as": "level2", + } + } + ], + "as": "level1", + } + } + ], + expected=[ + { + "_id": 1, + "val": "from_outer", + "level1": [ + { + "_id": 10, + "level2": [ + { + "_id": 10, + "level3": [{"_id": 10, "deep": "from_outer"}], + } + ], + } + ], + } + ], + msg="$lookup outer let var should be accessible in a triply-nested sub-pipeline (L3)", + ), + LookupTestCase( + "outer_and_inner_let_both_accessible", + docs=[{"_id": 1, "a": "outer_a"}], + foreign_docs=[{"_id": 10, "b": "inner_b"}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "$a"}, + "pipeline": [ + { + "$lookup": { + "from": FOREIGN, + "let": {"y": "$b"}, + "pipeline": [ + { + "$addFields": { + "from_outer": "$$x", + "from_inner": "$$y", + } + } + ], + "as": "nested", + } + } + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "a": "outer_a", + "joined": [ + { + "_id": 10, + "b": "inner_b", + "nested": [ + { + "_id": 10, + "b": "inner_b", + "from_outer": "outer_a", + "from_inner": "inner_b", + } + ], + } + ], + } + ], + msg=( + "$lookup nested let should allow both outer $$x and inner" + " $$y to be accessible in the innermost pipeline" + ), + ), + LookupTestCase( + "outer_let_in_inner_match_expr", + docs=[{"_id": 1, "ref": "target"}], + foreign_docs=[ + {"_id": 10, "tag": "target"}, + {"_id": 11, "tag": "other"}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"r": "$ref"}, + "pipeline": [ + { + "$lookup": { + "from": FOREIGN, + "pipeline": [{"$match": {"$expr": {"$eq": ["$tag", "$$r"]}}}], + "as": "inner_match", + } + } + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "ref": "target", + "joined": [ + { + "_id": 10, + "tag": "target", + "inner_match": [{"_id": 10, "tag": "target"}], + }, + { + "_id": 11, + "tag": "other", + "inner_match": [{"_id": 10, "tag": "target"}], + }, + ], + } + ], + msg=( + "$lookup outer let var should be usable in $match $expr" + " inside a nested $lookup sub-pipeline" + ), + ), + LookupTestCase( + "three_levels_each_with_own_let", + docs=[{"_id": 1, "a": "L1"}], + foreign_docs=[{"_id": 10, "b": "L2", "c": "L3"}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"v1": "$a"}, + "pipeline": [ + { + "$lookup": { + "from": FOREIGN, + "let": {"v2": "$b"}, + "pipeline": [ + { + "$lookup": { + "from": FOREIGN, + "let": {"v3": "$c"}, + "pipeline": [ + { + "$addFields": { + "r1": "$$v1", + "r2": "$$v2", + "r3": "$$v3", + } + } + ], + "as": "deep", + } + } + ], + "as": "mid", + } + } + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "a": "L1", + "joined": [ + { + "_id": 10, + "b": "L2", + "c": "L3", + "mid": [ + { + "_id": 10, + "b": "L2", + "c": "L3", + "deep": [ + { + "_id": 10, + "b": "L2", + "c": "L3", + "r1": "L1", + "r2": "L2", + "r3": "L3", + } + ], + } + ], + } + ], + } + ], + msg=( + "$lookup 3-level nesting where each level defines its own let" + " should make all three variables accessible at the deepest level" + ), + ), +] + +# --- Section 2: Let Shadowing in Nested Lookups --- +# Existing coverage: basic inner shadow overwrites outer +# New: partial shadow, shadow doesn't leak up, progressive shadow, type change in shadow + +LOOKUP_NESTED_SHADOW_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "partial_shadow_inner_redefines_one_of_two", + docs=[{"_id": 1, "a": "outer_a", "b": "outer_b"}], + foreign_docs=[{"_id": 10, "c": "inner_c"}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "$a", "y": "$b"}, + "pipeline": [ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "$c"}, + "pipeline": [ + { + "$addFields": { + "rx": "$$x", + "ry": "$$y", + } + } + ], + "as": "nested", + } + } + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "a": "outer_a", + "b": "outer_b", + "joined": [ + { + "_id": 10, + "c": "inner_c", + "nested": [ + { + "_id": 10, + "c": "inner_c", + "rx": "inner_c", + "ry": "outer_b", + } + ], + } + ], + } + ], + msg=( + "$lookup inner let redefining only x should shadow x" + " while outer y remains accessible" + ), + ), + LookupTestCase( + "shadow_does_not_leak_up", + docs=[{"_id": 1, "val": "outer_val"}], + foreign_docs=[{"_id": 10, "inner_val": "shadowed"}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "$val"}, + "pipeline": [ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "$inner_val"}, + "pipeline": [{"$addFields": {"inner_x": "$$x"}}], + "as": "inner", + } + }, + {"$addFields": {"after_inner_x": "$$x"}}, + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "val": "outer_val", + "joined": [ + { + "_id": 10, + "inner_val": "shadowed", + "inner": [{"_id": 10, "inner_val": "shadowed", "inner_x": "shadowed"}], + "after_inner_x": "outer_val", + } + ], + } + ], + msg=( + "$lookup inner shadow should not leak upward — after inner" + " lookup exits, outer scope sees original $$x value" + ), + ), + LookupTestCase( + "progressive_shadow_three_levels", + docs=[{"_id": 1, "v": "L1"}], + foreign_docs=[{"_id": 10, "v": "L2"}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "$v"}, + "pipeline": [ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "$v"}, + "pipeline": [ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "deepest"}, + "pipeline": [{"$addFields": {"val": "$$x"}}], + "as": "l3", + } + } + ], + "as": "l2", + } + } + ], + "as": "l1", + } + } + ], + expected=[ + { + "_id": 1, + "v": "L1", + "l1": [ + { + "_id": 10, + "v": "L2", + "l2": [ + { + "_id": 10, + "v": "L2", + "l3": [{"_id": 10, "v": "L2", "val": "deepest"}], + } + ], + } + ], + } + ], + msg=( + "$lookup triple progressive shadow: each level redefines $$x," + " deepest sees its own value" + ), + ), + LookupTestCase( + "shadow_with_different_type", + docs=[{"_id": 1, "x": 42}], + foreign_docs=[{"_id": 10, "x": "hello"}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"v": "$x"}, + "pipeline": [ + { + "$lookup": { + "from": FOREIGN, + "let": {"v": "$x"}, + "pipeline": [ + { + "$addFields": { + "val": "$$v", + "t": {"$type": "$$v"}, + } + } + ], + "as": "nested", + } + } + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "x": 42, + "joined": [ + { + "_id": 10, + "x": "hello", + "nested": [{"_id": 10, "x": "hello", "val": "hello", "t": "string"}], + } + ], + } + ], + msg=( + "$lookup inner shadow can change the type of a variable" + " — outer int shadowed by inner string" + ), + ), +] + +# --- Section 3: Nested Lookup Structure and Edge Cases --- + +LOOKUP_NESTED_STRUCTURE_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "nested_result_is_array_of_docs_with_arrays", + docs=[{"_id": 1, "ref": "a"}], + foreign_docs=[ + {"_id": 10, "tag": "a", "sub_ref": "x"}, + {"_id": 11, "tag": "a", "sub_ref": "y"}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"r": "$ref"}, + "pipeline": [ + {"$match": {"$expr": {"$eq": ["$tag", "$$r"]}}}, + { + "$lookup": { + "from": FOREIGN, + "let": {"sr": "$sub_ref"}, + "pipeline": [{"$match": {"$expr": {"$eq": ["$tag", "$$sr"]}}}], + "as": "deep", + } + }, + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "ref": "a", + "joined": [ + {"_id": 10, "tag": "a", "sub_ref": "x", "deep": []}, + {"_id": 11, "tag": "a", "sub_ref": "y", "deep": []}, + ], + } + ], + msg=( + "$lookup nested result should be array of docs each containing" + " their own inner 'deep' array field" + ), + ), + LookupTestCase( + "inner_as_same_name_as_outer_no_conflict", + docs=[{"_id": 1, "v": "outer"}], + foreign_docs=[{"_id": 10, "v": "mid"}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "$v"}, + "pipeline": [ + { + "$lookup": { + "from": FOREIGN, + "pipeline": [{"$addFields": {"src": "$$x"}}], + "as": "joined", + } + } + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "v": "outer", + "joined": [ + { + "_id": 10, + "v": "mid", + "joined": [{"_id": 10, "v": "mid", "src": "outer"}], + } + ], + } + ], + msg=( + "$lookup inner 'as' with same name as outer 'as' should" + " not conflict — they exist in different document contexts" + ), + ), + LookupTestCase( + "inner_let_references_field_from_addFields", + docs=[{"_id": 1, "base": 10}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"b": "$base"}, + "pipeline": [ + {"$addFields": {"computed": {"$multiply": ["$$b", 2]}}}, + { + "$lookup": { + "from": FOREIGN, + "let": {"c": "$computed"}, + "pipeline": [{"$addFields": {"result": "$$c"}}], + "as": "inner", + } + }, + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "base": 10, + "joined": [ + { + "_id": 10, + "computed": 20, + "inner": [{"_id": 10, "result": 20}], + } + ], + } + ], + msg=( + "$lookup inner let should be able to reference a field" + " created by a preceding $addFields in the same sub-pipeline" + ), + ), + LookupTestCase( + "self_join_at_depth", + docs=[ + {"_id": 1, "name": "root", "parent": None}, + {"_id": 2, "name": "child", "parent": 1}, + ], + foreign_docs=[ + {"_id": 1, "name": "root", "parent": None}, + {"_id": 2, "name": "child", "parent": 1}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"pid": "$_id"}, + "pipeline": [{"$match": {"$expr": {"$eq": ["$parent", "$$pid"]}}}], + "as": "children", + } + } + ], + expected=[ + { + "_id": 1, + "name": "root", + "parent": None, + "children": [{"_id": 2, "name": "child", "parent": 1}], + }, + {"_id": 2, "name": "child", "parent": 1, "children": []}, + ], + msg=( + "$lookup correlated self-join should find children for each" + " document where foreign parent == outer _id" + ), + ), +] + + +# --- Combine all tests --- +LOOKUP_CORRELATED_NESTED_ALL: list[LookupTestCase] = ( + LOOKUP_NESTED_PROPAGATION_TESTS + LOOKUP_NESTED_SHADOW_TESTS + LOOKUP_NESTED_STRUCTURE_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(LOOKUP_CORRELATED_NESTED_ALL)) +def test_lookup_correlated_nested(collection, test_case: LookupTestCase): + """Test $lookup correlated subquery nested lookup scoping and propagation.""" + with setup_lookup(collection, test_case) as foreign_name: + command = build_lookup_command(collection, test_case, foreign_name) + result = execute_command(collection, command) + 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/lookup/correlated/test_lookup_correlated_project.py b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_project.py new file mode 100644 index 000000000..191661ee8 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_project.py @@ -0,0 +1,639 @@ +"""Tests for $lookup correlated subquery — let variables in $project/$addFields and other stages. + +Covers let variable direct access in non-$match stages: $addFields, $project, +$group, $redact, $replaceRoot. Includes conditional logic, array/string +manipulation, and type preservation through sub-pipeline stages. +""" + +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.lookup.utils.lookup_common import ( + FOREIGN, + LookupTestCase, + build_lookup_command, + setup_lookup, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# --- Section 1: Basic Direct Access in $addFields and $project --- + +LOOKUP_PROJECT_BASIC_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "project_let_var_in_computed_field", + docs=[{"_id": 1, "label": "outer_label"}], + foreign_docs=[{"_id": 10, "name": "item_a"}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"lbl": "$label"}, + "pipeline": [{"$project": {"_id": 1, "name": 1, "outerLabel": "$$lbl"}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "label": "outer_label", + "joined": [{"_id": 10, "name": "item_a", "outerLabel": "outer_label"}], + } + ], + msg="$lookup $project should access let variable directly as computed field value", + ), + LookupTestCase( + "project_concat_with_let_var", + docs=[{"_id": 1, "label": "PREFIX"}], + foreign_docs=[{"_id": 10, "name": "item"}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"lbl": "$label"}, + "pipeline": [ + { + "$project": { + "_id": 1, + "combined": {"$concat": ["$$lbl", " - ", "$name"]}, + } + } + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "label": "PREFIX", + "joined": [{"_id": 10, "combined": "PREFIX - item"}], + } + ], + msg="$lookup $project with $concat should combine let var with foreign field", + ), + LookupTestCase( + "project_arithmetic_with_let_var", + docs=[{"_id": 1, "unitPrice": 5}], + foreign_docs=[{"_id": 10, "qty": 3}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"price": "$unitPrice"}, + "pipeline": [ + { + "$project": { + "_id": 0, + "total": {"$multiply": ["$qty", "$$price"]}, + } + } + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "unitPrice": 5, + "joined": [{"total": 15}], + } + ], + msg="$lookup $project with $multiply should compute arithmetic using let var", + ), +] + +# --- Section 2: Conditional Logic with Let Variables --- + +LOOKUP_PROJECT_CONDITIONAL_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "addFields_cond_with_let_var", + docs=[{"_id": 1, "threshold": 60}], + foreign_docs=[ + {"_id": 10, "score": 80}, + {"_id": 11, "score": 40}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"thr": "$threshold"}, + "pipeline": [ + { + "$addFields": { + "label": { + "$cond": [ + {"$gte": ["$score", "$$thr"]}, + "pass", + "fail", + ] + } + } + } + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "threshold": 60, + "joined": [ + {"_id": 10, "score": 80, "label": "pass"}, + {"_id": 11, "score": 40, "label": "fail"}, + ], + } + ], + msg="$lookup $addFields with $cond should classify based on let var threshold", + ), + LookupTestCase( + "addFields_switch_with_let_var", + docs=[{"_id": 1, "highMark": 80}], + foreign_docs=[ + {"_id": 10, "amount": 90}, + {"_id": 11, "amount": 50}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"high": "$highMark"}, + "pipeline": [ + { + "$addFields": { + "tier": { + "$switch": { + "branches": [ + { + "case": {"$gte": ["$amount", "$$high"]}, + "then": "premium", + } + ], + "default": "standard", + } + } + } + } + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "highMark": 80, + "joined": [ + {"_id": 10, "amount": 90, "tier": "premium"}, + {"_id": 11, "amount": 50, "tier": "standard"}, + ], + } + ], + msg="$lookup $addFields with $switch should branch using let var threshold", + ), + LookupTestCase( + "addFields_ifNull_let_var_as_default", + docs=[{"_id": 1, "defaultName": "Anonymous"}], + foreign_docs=[ + {"_id": 10, "name": "Alice"}, + {"_id": 11}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"dflt": "$defaultName"}, + "pipeline": [{"$addFields": {"displayName": {"$ifNull": ["$name", "$$dflt"]}}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "defaultName": "Anonymous", + "joined": [ + {"_id": 10, "name": "Alice", "displayName": "Alice"}, + {"_id": 11, "displayName": "Anonymous"}, + ], + } + ], + msg="$lookup $addFields with $ifNull should use let var as fallback when field missing", + ), +] + +# --- Section 3: Array/String Manipulation with Let Variables --- + +LOOKUP_PROJECT_ARRAY_STRING_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "addFields_concatArrays_with_let_var", + docs=[{"_id": 1, "extra": ["x", "y"]}], + foreign_docs=[{"_id": 10, "tags": ["a", "b"]}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"extras": "$extra"}, + "pipeline": [ + {"$addFields": {"allTags": {"$concatArrays": ["$tags", "$$extras"]}}} + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "extra": ["x", "y"], + "joined": [{"_id": 10, "tags": ["a", "b"], "allTags": ["a", "b", "x", "y"]}], + } + ], + msg="$lookup $addFields with $concatArrays should append let var array to foreign array", + ), + LookupTestCase( + "addFields_string_concat_path_with_let_var", + docs=[{"_id": 1, "basePath": "/products"}], + foreign_docs=[{"_id": 10, "slug": "widget"}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"base": "$basePath"}, + "pipeline": [ + {"$addFields": {"fullPath": {"$concat": ["$$base", "/", "$slug"]}}} + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "basePath": "/products", + "joined": [{"_id": 10, "slug": "widget", "fullPath": "/products/widget"}], + } + ], + msg="$lookup $addFields with $concat should build path from let var and foreign field", + ), + LookupTestCase( + "addFields_filter_threshold_from_let_var", + docs=[{"_id": 1, "minScore": 70}], + foreign_docs=[{"_id": 10, "scores": [50, 75, 90, 60]}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"min": "$minScore"}, + "pipeline": [ + { + "$addFields": { + "highScores": { + "$filter": { + "input": "$scores", + "cond": {"$gte": ["$$this", "$$min"]}, + } + } + } + } + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "minScore": 70, + "joined": [{"_id": 10, "scores": [50, 75, 90, 60], "highScores": [75, 90]}], + } + ], + msg="$lookup $addFields with $filter should use let var as threshold for array filtering", + ), + LookupTestCase( + "addFields_map_with_let_var_multiplier", + docs=[{"_id": 1, "rate": 1.1}], + foreign_docs=[{"_id": 10, "prices": [10, 20, 30]}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"r": "$rate"}, + "pipeline": [ + { + "$addFields": { + "adjusted": { + "$map": { + "input": "$prices", + "in": {"$multiply": ["$$this", "$$r"]}, + } + } + } + } + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "rate": 1.1, + "joined": [ + { + "_id": 10, + "prices": [10, 20, 30], + "adjusted": [11.0, 22.0, 33.0], + } + ], + } + ], + msg="$lookup $addFields with $map should scale array values using let var multiplier", + ), +] + +# --- Section 4: Let Variables in $group Within Sub-Pipeline --- + +LOOKUP_PROJECT_GROUP_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "group_let_var_as_id", + docs=[{"_id": 1, "cat": "electronics"}], + foreign_docs=[ + {"_id": 10, "item": "phone"}, + {"_id": 11, "item": "laptop"}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"category": "$cat"}, + "pipeline": [{"$group": {"_id": "$$category", "count": {"$sum": 1}}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "cat": "electronics", + "joined": [{"_id": "electronics", "count": 2}], + } + ], + msg="$lookup $group with let var as _id should group all foreign docs under that value", + ), + LookupTestCase( + "group_conditional_count_with_let_var", + docs=[{"_id": 1, "minVal": 50}], + foreign_docs=[ + {"_id": 10, "val": 80}, + {"_id": 11, "val": 30}, + {"_id": 12, "val": 60}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"min": "$minVal"}, + "pipeline": [ + { + "$group": { + "_id": None, + "above": {"$sum": {"$cond": [{"$gte": ["$val", "$$min"]}, 1, 0]}}, + } + } + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "minVal": 50, + "joined": [{"_id": None, "above": 2}], + } + ], + msg="$lookup $group with $cond using let var should count docs meeting threshold", + ), +] + +# --- Section 5: Let Variables in $redact --- + +LOOKUP_PROJECT_REDACT_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "redact_with_let_var", + docs=[{"_id": 1, "accessLevel": 2}], + foreign_docs=[ + {"_id": 10, "level": 1, "data": "public"}, + {"_id": 11, "level": 2, "data": "restricted"}, + {"_id": 12, "level": 3, "data": "secret"}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"access": "$accessLevel"}, + "pipeline": [ + { + "$redact": { + "$cond": [ + {"$lte": ["$level", "$$access"]}, + "$$KEEP", + "$$PRUNE", + ] + } + } + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "accessLevel": 2, + "joined": [ + {"_id": 10, "level": 1, "data": "public"}, + {"_id": 11, "level": 2, "data": "restricted"}, + ], + } + ], + msg="$lookup $redact with let var should keep/prune docs based on access level", + ), +] + +# --- Section 6: Let Variables in $replaceRoot --- + +LOOKUP_PROJECT_REPLACEROOT_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "replaceRoot_with_let_var", + docs=[{"_id": 1, "context": "outer_ctx"}], + foreign_docs=[{"_id": 10, "data": "foreign_data"}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"ctx": "$context"}, + "pipeline": [ + { + "$replaceRoot": { + "newRoot": { + "originalData": "$data", + "fromOuter": "$$ctx", + } + } + } + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "context": "outer_ctx", + "joined": [{"originalData": "foreign_data", "fromOuter": "outer_ctx"}], + } + ], + msg="$lookup $replaceRoot with let var should include it in the new root document", + ), +] + +# --- Section 7: Type Preservation Through $addFields --- + +_OID = ObjectId("bbbbbbbbbbbbbbbbbbbbbbbb") +_DATE = datetime(2024, 3, 15, 12, 0, 0, tzinfo=timezone.utc) + +LOOKUP_PROJECT_TYPE_PRESERVATION_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "type_preservation_int_through_addFields", + docs=[{"_id": 1, "v": 42}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "$v"}, + "pipeline": [{"$addFields": {"val": "$$x", "t": {"$type": "$$x"}}}], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "v": 42, "joined": [{"_id": 10, "val": 42, "t": "int"}]}], + msg="$lookup let var int type should be preserved through $addFields in sub-pipeline", + ), + LookupTestCase( + "type_preservation_decimal128_through_addFields", + docs=[{"_id": 1, "v": Decimal128("99.99")}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "$v"}, + "pipeline": [{"$addFields": {"val": "$$x", "t": {"$type": "$$x"}}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "v": Decimal128("99.99"), + "joined": [{"_id": 10, "val": Decimal128("99.99"), "t": "decimal"}], + } + ], + msg="$lookup let var Decimal128 type should be preserved through $addFields", + ), + LookupTestCase( + "type_preservation_date_through_addFields", + docs=[{"_id": 1, "v": _DATE}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "$v"}, + "pipeline": [{"$addFields": {"val": "$$x", "t": {"$type": "$$x"}}}], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "v": _DATE, "joined": [{"_id": 10, "val": _DATE, "t": "date"}]}], + msg="$lookup let var date type should be preserved through $addFields", + ), + LookupTestCase( + "type_preservation_objectid_through_addFields", + docs=[{"_id": 1, "v": _OID}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "$v"}, + "pipeline": [{"$addFields": {"val": "$$x", "t": {"$type": "$$x"}}}], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "v": _OID, "joined": [{"_id": 10, "val": _OID, "t": "objectId"}]}], + msg="$lookup let var ObjectId type should be preserved through $addFields", + ), + LookupTestCase( + "type_preservation_long_through_addFields", + docs=[{"_id": 1, "v": Int64(123456789012)}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "$v"}, + "pipeline": [{"$addFields": {"val": "$$x", "t": {"$type": "$$x"}}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "v": Int64(123456789012), + "joined": [{"_id": 10, "val": Int64(123456789012), "t": "long"}], + } + ], + msg="$lookup let var Int64 type should be preserved through $addFields", + ), +] + + +# --- Combine all tests --- +LOOKUP_CORRELATED_PROJECT_ALL: list[LookupTestCase] = ( + LOOKUP_PROJECT_BASIC_TESTS + + LOOKUP_PROJECT_CONDITIONAL_TESTS + + LOOKUP_PROJECT_ARRAY_STRING_TESTS + + LOOKUP_PROJECT_GROUP_TESTS + + LOOKUP_PROJECT_REDACT_TESTS + + LOOKUP_PROJECT_REPLACEROOT_TESTS + + LOOKUP_PROJECT_TYPE_PRESERVATION_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(LOOKUP_CORRELATED_PROJECT_ALL)) +def test_lookup_correlated_project(collection, test_case: LookupTestCase): + """Test $lookup correlated subquery let variables in $project/$addFields and other stages.""" + with setup_lookup(collection, test_case) as foreign_name: + command = build_lookup_command(collection, test_case, foreign_name) + result = execute_command(collection, command) + 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/lookup/correlated/test_lookup_correlated_result_semantics.py b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_result_semantics.py new file mode 100644 index 000000000..a8d931a10 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_result_semantics.py @@ -0,0 +1,276 @@ +"""Tests for $lookup correlated subquery — per-document variation and result cardinality. + +Covers the fundamental correlated behavior: let variable changes per outer +document, producing different join results. Also covers result array properties +(empty, single, many, ordering, $sort/$limit/$skip in sub-pipeline). +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.stages.lookup.utils.lookup_common import ( + FOREIGN, + LookupTestCase, + build_lookup_command, + setup_lookup, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +LOOKUP_RESULT_SEMANTICS_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "per_doc_variation_distinct_let_values", + docs=[ + {"_id": 1, "cat": "A"}, + {"_id": 2, "cat": "B"}, + {"_id": 3, "cat": "C"}, + ], + foreign_docs=[ + {"_id": 10, "type": "A", "val": 1}, + {"_id": 11, "type": "B", "val": 2}, + {"_id": 12, "type": "B", "val": 3}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"c": "$cat"}, + "pipeline": [{"$match": {"$expr": {"$eq": ["$type", "$$c"]}}}], + "as": "joined", + } + } + ], + expected=[ + {"_id": 1, "cat": "A", "joined": [{"_id": 10, "type": "A", "val": 1}]}, + { + "_id": 2, + "cat": "B", + "joined": [{"_id": 11, "type": "B", "val": 2}, {"_id": 12, "type": "B", "val": 3}], + }, + {"_id": 3, "cat": "C", "joined": []}, + ], + msg=( + "$lookup correlated join should produce different results" + " per outer document based on each doc's let var value" + ), + ), + LookupTestCase( + "per_doc_duplicate_let_values_same_result", + docs=[ + {"_id": 1, "cat": "A"}, + {"_id": 2, "cat": "A"}, + ], + foreign_docs=[ + {"_id": 10, "type": "A"}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"c": "$cat"}, + "pipeline": [{"$match": {"$expr": {"$eq": ["$type", "$$c"]}}}], + "as": "joined", + } + } + ], + expected=[ + {"_id": 1, "cat": "A", "joined": [{"_id": 10, "type": "A"}]}, + {"_id": 2, "cat": "A", "joined": [{"_id": 10, "type": "A"}]}, + ], + msg=( + "$lookup correlated with duplicate let values should produce" + " identical joined results for both outer docs" + ), + ), + LookupTestCase( + "result_empty_array_no_matches", + docs=[{"_id": 1, "cat": "nonexistent"}], + foreign_docs=[{"_id": 10, "type": "A"}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"c": "$cat"}, + "pipeline": [{"$match": {"$expr": {"$eq": ["$type", "$$c"]}}}], + "as": "joined", + } + } + ], + expected=[ + {"_id": 1, "cat": "nonexistent", "joined": []}, + ], + msg="$lookup correlated with no matching foreign docs should produce empty array", + ), + LookupTestCase( + "result_sort_in_sub_pipeline", + docs=[{"_id": 1, "cat": "A"}], + foreign_docs=[ + {"_id": 12, "type": "A", "score": 50}, + {"_id": 10, "type": "A", "score": 90}, + {"_id": 11, "type": "A", "score": 70}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"c": "$cat"}, + "pipeline": [ + {"$match": {"$expr": {"$eq": ["$type", "$$c"]}}}, + {"$sort": {"score": -1}}, + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "cat": "A", + "joined": [ + {"_id": 10, "type": "A", "score": 90}, + {"_id": 11, "type": "A", "score": 70}, + {"_id": 12, "type": "A", "score": 50}, + ], + } + ], + msg="$lookup sub-pipeline $sort should determine order within joined array", + ), + LookupTestCase( + "result_limit_in_sub_pipeline", + docs=[{"_id": 1, "cat": "A"}], + foreign_docs=[ + {"_id": 10, "type": "A", "score": 90}, + {"_id": 11, "type": "A", "score": 70}, + {"_id": 12, "type": "A", "score": 50}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"c": "$cat"}, + "pipeline": [ + {"$match": {"$expr": {"$eq": ["$type", "$$c"]}}}, + {"$sort": {"score": -1}}, + {"$limit": 2}, + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "cat": "A", + "joined": [ + {"_id": 10, "type": "A", "score": 90}, + {"_id": 11, "type": "A", "score": 70}, + ], + } + ], + msg="$lookup sub-pipeline $limit should restrict joined array to N docs", + ), + LookupTestCase( + "result_skip_in_sub_pipeline", + docs=[{"_id": 1, "cat": "A"}], + foreign_docs=[ + {"_id": 10, "type": "A", "score": 90}, + {"_id": 11, "type": "A", "score": 70}, + {"_id": 12, "type": "A", "score": 50}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"c": "$cat"}, + "pipeline": [ + {"$match": {"$expr": {"$eq": ["$type", "$$c"]}}}, + {"$sort": {"score": -1}}, + {"$skip": 1}, + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "cat": "A", + "joined": [ + {"_id": 11, "type": "A", "score": 70}, + {"_id": 12, "type": "A", "score": 50}, + ], + } + ], + msg="$lookup sub-pipeline $skip should skip first N joined results", + ), + LookupTestCase( + "result_project_shapes_joined_docs", + docs=[{"_id": 1, "cat": "A"}], + foreign_docs=[ + {"_id": 10, "type": "A", "name": "Widget", "secret": "hidden"}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"c": "$cat"}, + "pipeline": [ + {"$match": {"$expr": {"$eq": ["$type", "$$c"]}}}, + {"$project": {"name": 1, "_id": 0}}, + ], + "as": "joined", + } + } + ], + expected=[ + {"_id": 1, "cat": "A", "joined": [{"name": "Widget"}]}, + ], + msg="$lookup sub-pipeline $project should reshape docs in joined array", + ), + LookupTestCase( + "outer_document_order_preserved", + docs=[ + {"_id": 3, "cat": "C"}, + {"_id": 1, "cat": "A"}, + {"_id": 2, "cat": "B"}, + ], + foreign_docs=[ + {"_id": 10, "type": "A"}, + {"_id": 11, "type": "B"}, + {"_id": 12, "type": "C"}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"c": "$cat"}, + "pipeline": [{"$match": {"$expr": {"$eq": ["$type", "$$c"]}}}], + "as": "joined", + } + } + ], + expected=[ + {"_id": 3, "cat": "C", "joined": [{"_id": 12, "type": "C"}]}, + {"_id": 1, "cat": "A", "joined": [{"_id": 10, "type": "A"}]}, + {"_id": 2, "cat": "B", "joined": [{"_id": 11, "type": "B"}]}, + ], + msg="$lookup should preserve outer document order regardless of join results", + ), +] + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(LOOKUP_RESULT_SEMANTICS_TESTS)) +def test_lookup_correlated_result_semantics(collection, test_case: LookupTestCase): + """Test $lookup correlated subquery per-document variation and result cardinality.""" + with setup_lookup(collection, test_case) as foreign_name: + command = build_lookup_command(collection, test_case, foreign_name) + result = execute_command(collection, command) + 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/lookup/test_lookup_correlated_subquery.py b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_subquery.py similarity index 100% rename from documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_correlated_subquery.py rename to documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_subquery.py diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_system_vars.py b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_system_vars.py new file mode 100644 index 000000000..ac100d517 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_system_vars.py @@ -0,0 +1,191 @@ +"""Tests for $lookup correlated subquery — $$ROOT/$$CURRENT context inside sub-pipeline. + +Inside the sub-pipeline, $$ROOT and $$CURRENT refer to the FOREIGN document +context, not the outer document. Outer context must be captured via let. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.stages.lookup.utils.lookup_common import ( + FOREIGN, + LookupTestCase, + build_lookup_command, + setup_lookup, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +LOOKUP_SYSTEM_VARS_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "ROOT_inside_sub_pipeline_refers_to_foreign", + docs=[{"_id": 1, "name": "outer_doc"}], + foreign_docs=[{"_id": 10, "name": "foreign_doc"}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "pipeline": [{"$addFields": {"rootName": "$$ROOT.name"}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "name": "outer_doc", + "joined": [{"_id": 10, "name": "foreign_doc", "rootName": "foreign_doc"}], + } + ], + msg="$$ROOT inside sub-pipeline should refer to the foreign document, not outer", + ), + LookupTestCase( + "CURRENT_inside_sub_pipeline_refers_to_foreign", + docs=[{"_id": 1, "name": "outer_doc"}], + foreign_docs=[{"_id": 10, "name": "foreign_doc"}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "pipeline": [{"$addFields": {"currentName": "$$CURRENT.name"}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "name": "outer_doc", + "joined": [{"_id": 10, "name": "foreign_doc", "currentName": "foreign_doc"}], + } + ], + msg="$$CURRENT inside sub-pipeline should refer to the foreign document, not outer", + ), + LookupTestCase( + "let_captures_outer_ROOT_vs_inner_ROOT", + docs=[{"_id": 1, "name": "outer"}], + foreign_docs=[{"_id": 10, "name": "foreign"}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"outerRoot": "$$ROOT"}, + "pipeline": [ + { + "$addFields": { + "outerName": "$$outerRoot.name", + "innerName": "$$ROOT.name", + } + } + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "name": "outer", + "joined": [ + { + "_id": 10, + "name": "foreign", + "outerName": "outer", + "innerName": "foreign", + } + ], + } + ], + msg=( + "let capturing $$ROOT should preserve outer doc context while" + " $$ROOT inside pipeline refers to foreign doc" + ), + ), + LookupTestCase( + "nested_ROOT_refers_to_innermost_foreign", + docs=[{"_id": 1, "name": "L0"}], + foreign_docs=[{"_id": 10, "name": "L1"}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"outerRoot": "$$ROOT"}, + "pipeline": [ + { + "$lookup": { + "from": FOREIGN, + "pipeline": [ + { + "$addFields": { + "deepRoot": "$$ROOT.name", + "fromOuter": "$$outerRoot.name", + } + } + ], + "as": "inner", + } + } + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "name": "L0", + "joined": [ + { + "_id": 10, + "name": "L1", + "inner": [ + { + "_id": 10, + "name": "L1", + "deepRoot": "L1", + "fromOuter": "L0", + } + ], + } + ], + } + ], + msg=( + "In nested $lookup, $$ROOT in innermost pipeline refers to" + " that level's foreign doc while outer let preserves L0 context" + ), + ), + LookupTestCase( + "NOW_accessible_inside_sub_pipeline", + docs=[{"_id": 1}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "pipeline": [{"$addFields": {"nowType": {"$type": "$$NOW"}}}], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "joined": [{"_id": 10, "nowType": "date"}]}], + msg="$$NOW should be accessible inside $lookup sub-pipeline as date type", + ), +] + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(LOOKUP_SYSTEM_VARS_TESTS)) +def test_lookup_correlated_system_vars(collection, test_case: LookupTestCase): + """Test $lookup correlated subquery $$ROOT/$$CURRENT/$$NOW context semantics.""" + with setup_lookup(collection, test_case) as foreign_name: + command = build_lookup_command(collection, test_case, foreign_name) + result = execute_command(collection, command) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) From 1137d36a78cbdf544040fdc037ac8de2afb8fac1 Mon Sep 17 00:00:00 2001 From: PatersonProjects Date: Thu, 9 Jul 2026 13:32:53 -0700 Subject: [PATCH 3/4] Moved correlated tests to maintain folder structure, added missing cases, removed out of scope cases Signed-off-by: PatersonProjects --- .../stages/lookup/correlated/__init__.py | 0 .../test_lookup_correlated_collation.py | 220 --------- .../test_lookup_correlated_concise.py | 134 ------ ...test_lookup_concise_correlated_subquery.py | 102 ++++- .../test_lookup_correlated_composition.py | 5 +- .../test_lookup_correlated_edge_cases.py | 37 +- .../test_lookup_correlated_let_forms.py | 174 +++++-- .../test_lookup_correlated_match.py | 423 ++++++++++++------ .../test_lookup_correlated_nested.py | 169 ++++++- .../test_lookup_correlated_project.py | 69 ++- ...test_lookup_correlated_result_semantics.py | 3 + .../test_lookup_correlated_subquery.py | 59 +-- .../test_lookup_correlated_system_vars.py | 3 + 13 files changed, 771 insertions(+), 627 deletions(-) delete mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/__init__.py delete mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_collation.py delete mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_concise.py rename documentdb_tests/compatibility/tests/core/operator/stages/lookup/{correlated => }/test_lookup_concise_correlated_subquery.py (70%) rename documentdb_tests/compatibility/tests/core/operator/stages/lookup/{correlated => }/test_lookup_correlated_composition.py (97%) rename documentdb_tests/compatibility/tests/core/operator/stages/lookup/{correlated => }/test_lookup_correlated_edge_cases.py (91%) rename documentdb_tests/compatibility/tests/core/operator/stages/lookup/{correlated => }/test_lookup_correlated_let_forms.py (82%) rename documentdb_tests/compatibility/tests/core/operator/stages/lookup/{correlated => }/test_lookup_correlated_match.py (75%) rename documentdb_tests/compatibility/tests/core/operator/stages/lookup/{correlated => }/test_lookup_correlated_nested.py (78%) rename documentdb_tests/compatibility/tests/core/operator/stages/lookup/{correlated => }/test_lookup_correlated_project.py (90%) rename documentdb_tests/compatibility/tests/core/operator/stages/lookup/{correlated => }/test_lookup_correlated_result_semantics.py (97%) rename documentdb_tests/compatibility/tests/core/operator/stages/lookup/{correlated => }/test_lookup_correlated_subquery.py (90%) rename documentdb_tests/compatibility/tests/core/operator/stages/lookup/{correlated => }/test_lookup_correlated_system_vars.py (96%) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/__init__.py b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_collation.py b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_collation.py deleted file mode 100644 index 179f835d7..000000000 --- a/documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_collation.py +++ /dev/null @@ -1,220 +0,0 @@ -"""Tests for $lookup correlated subquery — collation interactions. - -Covers command-level collation propagation into sub-pipeline $expr comparisons, -collation through nested lookups, and collation edge cases. -""" - -from __future__ import annotations - -import pytest - -from documentdb_tests.compatibility.tests.core.operator.stages.lookup.utils.lookup_common import ( - FOREIGN, - LookupTestCase, - build_lookup_command, - build_lookup_pipeline, - setup_lookup, -) -from documentdb_tests.framework.assertions import assertResult -from documentdb_tests.framework.executor import execute_command -from documentdb_tests.framework.parametrize import pytest_params - -# --- Section 1: Command-Level Collation in Sub-Pipeline $expr --- - -LOOKUP_COLLATION_BASIC_TESTS: list[LookupTestCase] = [ - LookupTestCase( - "collation_strength1_case_insensitive_match", - docs=[{"_id": 1, "name": "Apple"}], - foreign_docs=[ - {"_id": 10, "product": "apple"}, - {"_id": 11, "product": "banana"}, - ], - pipeline=[ - { - "$lookup": { - "from": FOREIGN, - "let": {"n": "$name"}, - "pipeline": [{"$match": {"$expr": {"$eq": ["$product", "$$n"]}}}], - "as": "joined", - } - } - ], - expected=[ - { - "_id": 1, - "name": "Apple", - "joined": [{"_id": 10, "product": "apple"}], - } - ], - msg=( - "$lookup $expr $eq with collation strength 1 should match" - " case-insensitively: 'Apple' == 'apple'" - ), - ), - LookupTestCase( - "no_collation_binary_comparison", - docs=[{"_id": 1, "name": "Apple"}], - foreign_docs=[ - {"_id": 10, "product": "apple"}, - {"_id": 11, "product": "Apple"}, - ], - pipeline=[ - { - "$lookup": { - "from": FOREIGN, - "let": {"n": "$name"}, - "pipeline": [{"$match": {"$expr": {"$eq": ["$product", "$$n"]}}}], - "as": "joined", - } - } - ], - expected=[ - { - "_id": 1, - "name": "Apple", - "joined": [{"_id": 11, "product": "Apple"}], - } - ], - msg=( - "$lookup $expr $eq without collation should use binary" - " comparison: only exact case match" - ), - ), - LookupTestCase( - "collation_strength1_lt_ordering", - docs=[{"_id": 1, "threshold": "b"}], - foreign_docs=[ - {"_id": 10, "val": "A"}, - {"_id": 11, "val": "C"}, - {"_id": 12, "val": "a"}, - ], - pipeline=[ - { - "$lookup": { - "from": FOREIGN, - "let": {"thr": "$threshold"}, - "pipeline": [{"$match": {"$expr": {"$lt": ["$val", "$$thr"]}}}], - "as": "joined", - } - } - ], - expected=[ - { - "_id": 1, - "threshold": "b", - "joined": [ - {"_id": 10, "val": "A"}, - {"_id": 12, "val": "a"}, - ], - } - ], - msg=( - "$lookup $expr $lt with collation strength 1 should order" - " strings case-insensitively: 'A' and 'a' both < 'b'" - ), - ), -] - -# --- Section 2: Collation Does NOT Affect Output Values --- - -LOOKUP_COLLATION_OUTPUT_TESTS: list[LookupTestCase] = [ - LookupTestCase( - "collation_does_not_alter_addFields_output", - docs=[{"_id": 1, "label": "Apple"}], - foreign_docs=[{"_id": 10}], - pipeline=[ - { - "$lookup": { - "from": FOREIGN, - "let": {"x": "$label"}, - "pipeline": [{"$addFields": {"val": "$$x"}}], - "as": "joined", - } - } - ], - expected=[ - { - "_id": 1, - "label": "Apple", - "joined": [{"_id": 10, "val": "Apple"}], - } - ], - msg=( - "$lookup collation should NOT alter $addFields output values" - " — 'Apple' is stored as 'Apple' regardless of collation" - ), - ), -] - - -def _build_collation_command(collection, test_case, foreign_name, collation): - """Build aggregate command with collation option.""" - return { - "aggregate": collection.name, - "pipeline": build_lookup_pipeline(test_case, foreign_name), - "cursor": {}, - "collation": collation, - } - - -# Tests that require specific collation settings are run with custom commands. -# The parametrized tests below use the default (no collation / binary). -# Collation-specific tests are separate functions below. - -LOOKUP_COLLATION_NO_COLLATION_TESTS: list[LookupTestCase] = [ - LOOKUP_COLLATION_BASIC_TESTS[1], # no_collation_binary_comparison - LOOKUP_COLLATION_OUTPUT_TESTS[0], # does_not_alter_output -] - - -@pytest.mark.aggregate -@pytest.mark.parametrize("test_case", pytest_params(LOOKUP_COLLATION_NO_COLLATION_TESTS)) -def test_lookup_correlated_collation_binary(collection, test_case: LookupTestCase): - """Test $lookup correlated subquery with default binary collation.""" - with setup_lookup(collection, test_case) as foreign_name: - command = build_lookup_command(collection, test_case, foreign_name) - result = execute_command(collection, command) - assertResult( - result, - expected=test_case.expected, - error_code=test_case.error_code, - msg=test_case.msg, - ) - - -@pytest.mark.aggregate -def test_lookup_correlated_collation_strength1_eq(collection): - """Test $lookup $expr $eq with collation strength 1 matches case-insensitively.""" - test_case = LOOKUP_COLLATION_BASIC_TESTS[0] - with setup_lookup(collection, test_case) as foreign_name: - command = _build_collation_command( - collection, - test_case, - foreign_name, - {"locale": "en", "strength": 1}, - ) - result = execute_command(collection, command) - assertResult( - result, - expected=test_case.expected, - msg=test_case.msg, - ) - - -@pytest.mark.aggregate -def test_lookup_correlated_collation_strength1_lt(collection): - """Test $lookup $expr $lt with collation strength 1 orders case-insensitively.""" - test_case = LOOKUP_COLLATION_BASIC_TESTS[2] - with setup_lookup(collection, test_case) as foreign_name: - command = _build_collation_command( - collection, - test_case, - foreign_name, - {"locale": "en", "strength": 1}, - ) - result = execute_command(collection, command) - assertResult( - result, - expected=test_case.expected, - msg=test_case.msg, - ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_concise.py b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_concise.py deleted file mode 100644 index 8e12fb9b7..000000000 --- a/documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_concise.py +++ /dev/null @@ -1,134 +0,0 @@ -"""Tests for $lookup correlated subquery — concise correlated syntax (additional cases). - -Extends test_lookup_concise_correlated_subquery.py with additional concise -syntax cases: empty pipeline equivalence, no-match behavior, and array -localField matching. Basic equality+pipeline and let+equality combinations -are covered in the pre-existing file. -""" - -from __future__ import annotations - -import pytest - -from documentdb_tests.compatibility.tests.core.operator.stages.lookup.utils.lookup_common import ( - FOREIGN, - LookupTestCase, - build_lookup_command, - setup_lookup, -) -from documentdb_tests.framework.assertions import assertResult -from documentdb_tests.framework.executor import execute_command -from documentdb_tests.framework.parametrize import pytest_params - -LOOKUP_CONCISE_TESTS: list[LookupTestCase] = [ - LookupTestCase( - "concise_empty_pipeline_same_as_simple_equality", - docs=[{"_id": 1, "x": "val"}], - foreign_docs=[ - {"_id": 10, "y": "val"}, - {"_id": 11, "y": "other"}, - ], - pipeline=[ - { - "$lookup": { - "from": FOREIGN, - "localField": "x", - "foreignField": "y", - "pipeline": [], - "as": "joined", - } - } - ], - expected=[ - { - "_id": 1, - "x": "val", - "joined": [{"_id": 10, "y": "val"}], - } - ], - msg=( - "$lookup concise syntax with empty pipeline should be" - " equivalent to simple equality lookup" - ), - ), - LookupTestCase( - "concise_equality_no_match_pipeline_not_executed", - docs=[{"_id": 1, "x": "no_match"}], - foreign_docs=[ - {"_id": 10, "y": "val1"}, - {"_id": 11, "y": "val2"}, - ], - pipeline=[ - { - "$lookup": { - "from": FOREIGN, - "localField": "x", - "foreignField": "y", - "let": {"v": "$x"}, - "pipeline": [{"$addFields": {"src": "$$v"}}], - "as": "joined", - } - } - ], - expected=[ - { - "_id": 1, - "x": "no_match", - "joined": [], - } - ], - msg=( - "$lookup concise syntax with no equality matches should" - " result in empty array (pipeline never runs on empty set)" - ), - ), - LookupTestCase( - "concise_localField_array_matches_scalar", - docs=[{"_id": 1, "refs": [10, 20]}], - foreign_docs=[ - {"_id": 10, "val": "a"}, - {"_id": 20, "val": "b"}, - {"_id": 30, "val": "c"}, - ], - pipeline=[ - { - "$lookup": { - "from": FOREIGN, - "localField": "refs", - "foreignField": "_id", - "pipeline": [], - "as": "joined", - } - } - ], - expected=[ - { - "_id": 1, - "refs": [10, 20], - "joined": [ - {"_id": 10, "val": "a"}, - {"_id": 20, "val": "b"}, - ], - } - ], - msg=( - "$lookup concise syntax with array localField should match" - " each element against scalar foreignField" - ), - ), -] - - -@pytest.mark.aggregate -@pytest.mark.parametrize("test_case", pytest_params(LOOKUP_CONCISE_TESTS)) -def test_lookup_correlated_concise(collection, test_case: LookupTestCase): - """Test $lookup concise correlated subquery syntax (localField/foreignField + pipeline).""" - with setup_lookup(collection, test_case) as foreign_name: - command = build_lookup_command(collection, test_case, foreign_name) - result = execute_command(collection, command) - 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/lookup/correlated/test_lookup_concise_correlated_subquery.py b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_concise_correlated_subquery.py similarity index 70% rename from documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_concise_correlated_subquery.py rename to documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_concise_correlated_subquery.py index 53d9c686e..0d2a8e15a 100644 --- a/documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_concise_correlated_subquery.py +++ b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_concise_correlated_subquery.py @@ -1,4 +1,9 @@ -"""Tests for $lookup concise correlated subquery — localField + foreignField + pipeline.""" +"""Tests for $lookup concise correlated subquery — localField + foreignField + pipeline. + +Covers equality-match-then-pipeline, let combined with equality, null/empty-string +field degradation to uncorrelated, empty-pipeline equivalence, no-match behavior, +and array localField matching. +""" from __future__ import annotations @@ -212,6 +217,101 @@ " returning all foreign documents" ), ), + LookupTestCase( + "concise_empty_pipeline_same_as_simple_equality", + docs=[{"_id": 1, "x": "val"}], + foreign_docs=[ + {"_id": 10, "y": "val"}, + {"_id": 11, "y": "other"}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "localField": "x", + "foreignField": "y", + "pipeline": [], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "x": "val", + "joined": [{"_id": 10, "y": "val"}], + } + ], + msg=( + "$lookup concise syntax with empty pipeline should be" + " equivalent to simple equality lookup" + ), + ), + LookupTestCase( + "concise_equality_no_match_pipeline_not_executed", + docs=[{"_id": 1, "x": "no_match"}], + foreign_docs=[ + {"_id": 10, "y": "val1"}, + {"_id": 11, "y": "val2"}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "localField": "x", + "foreignField": "y", + "let": {"v": "$x"}, + "pipeline": [{"$addFields": {"src": "$$v"}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "x": "no_match", + "joined": [], + } + ], + msg=( + "$lookup concise syntax with no equality matches should" + " result in empty array (pipeline never runs on empty set)" + ), + ), + LookupTestCase( + "concise_localField_array_matches_scalar", + docs=[{"_id": 1, "refs": [10, 20]}], + foreign_docs=[ + {"_id": 10, "val": "a"}, + {"_id": 20, "val": "b"}, + {"_id": 30, "val": "c"}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "localField": "refs", + "foreignField": "_id", + "pipeline": [], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "refs": [10, 20], + "joined": [ + {"_id": 10, "val": "a"}, + {"_id": 20, "val": "b"}, + ], + } + ], + msg=( + "$lookup concise syntax with array localField should match" + " each element against scalar foreignField" + ), + ), ] diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_composition.py b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_correlated_composition.py similarity index 97% rename from documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_composition.py rename to documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_correlated_composition.py index 8cfc9350c..4647b86d0 100644 --- a/documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_composition.py +++ b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_correlated_composition.py @@ -18,6 +18,9 @@ from documentdb_tests.framework.executor import execute_command from documentdb_tests.framework.parametrize import pytest_params +# Property [Correlated Subquery — Stage Composition]: let variables remain +# accessible when the sub-pipeline contains other aggregation stages between +# the let binding and the $match or $addFields usage. LOOKUP_COMPOSITION_TESTS: list[LookupTestCase] = [ LookupTestCase( "let_var_after_unwind", @@ -92,7 +95,7 @@ "let": {"tgt": "$target"}, "pipeline": [ {"$addFields": {"diff": {"$abs": {"$subtract": ["$score", "$$tgt"]}}}}, - {"$sort": {"diff": 1}}, + {"$sort": {"diff": 1, "_id": 1}}, ], "as": "joined", } diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_edge_cases.py b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_correlated_edge_cases.py similarity index 91% rename from documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_edge_cases.py rename to documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_correlated_edge_cases.py index 95940c23d..12022487f 100644 --- a/documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_edge_cases.py +++ b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_correlated_edge_cases.py @@ -17,11 +17,18 @@ setup_lookup, ) from documentdb_tests.framework.assertions import assertResult, assertSuccessNaN -from documentdb_tests.framework.error_codes import BAD_VALUE_ERROR +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + LET_UNDEFINED_VARIABLE_ERROR, + TYPE_MISMATCH_ERROR, +) from documentdb_tests.framework.executor import execute_command from documentdb_tests.framework.parametrize import pytest_params -# --- Section 1: Let Expression Errors --- +# Property [Correlated Subquery — Edge Cases]: let expression error propagation, +# undefined variable behavior, numeric equivalence in $expr, special numeric +# values, $$REMOVE semantics, and path traversal on let variables. + LOOKUP_EDGE_ERROR_TESTS: list[LookupTestCase] = [ LookupTestCase( @@ -38,7 +45,7 @@ } } ], - error_code=14, # TYPE_MISMATCH_ERROR + error_code=TYPE_MISMATCH_ERROR, msg="$lookup let with $add type mismatch should propagate error", ), LookupTestCase( @@ -66,7 +73,6 @@ ), ] -# --- Section 2: Undefined Variable References --- LOOKUP_EDGE_UNDEFINED_VAR_TESTS: list[LookupTestCase] = [ LookupTestCase( @@ -83,29 +89,11 @@ } } ], - error_code=17276, + error_code=LET_UNDEFINED_VARIABLE_ERROR, msg="$lookup referencing undefined variable in $addFields should error", ), - LookupTestCase( - "empty_let_then_reference_var_errors", - docs=[{"_id": 1}], - foreign_docs=[{"_id": 10}], - pipeline=[ - { - "$lookup": { - "from": FOREIGN, - "let": {}, - "pipeline": [{"$addFields": {"val": "$$anyVar"}}], - "as": "joined", - } - } - ], - error_code=17276, - msg="$lookup with empty let then referencing $$anyVar should error", - ), ] -# --- Section 3: Numeric Equivalence in $expr Comparisons --- LOOKUP_EDGE_NUMERIC_TESTS: list[LookupTestCase] = [ LookupTestCase( @@ -170,7 +158,6 @@ ), ] -# --- Section 4: Special Numeric Values --- LOOKUP_EDGE_SPECIAL_VALUES_TESTS: list[LookupTestCase] = [ LookupTestCase( @@ -195,7 +182,6 @@ ), ] -# --- Section 5: $$REMOVE Semantics --- LOOKUP_EDGE_REMOVE_TESTS: list[LookupTestCase] = [ LookupTestCase( @@ -237,7 +223,6 @@ ), ] -# --- Section 6: Let Variable Path Traversal --- LOOKUP_EDGE_PATH_TRAVERSAL_TESTS: list[LookupTestCase] = [ LookupTestCase( diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_let_forms.py b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_correlated_let_forms.py similarity index 82% rename from documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_let_forms.py rename to documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_correlated_let_forms.py index 84496759d..eecd6618a 100644 --- a/documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_let_forms.py +++ b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_correlated_let_forms.py @@ -11,7 +11,7 @@ from datetime import datetime, timezone import pytest -from bson import Binary, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp +from bson import Decimal128, Int64, MaxKey, MinKey from documentdb_tests.compatibility.tests.core.operator.stages.lookup.utils.lookup_common import ( FOREIGN, @@ -22,16 +22,18 @@ 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 ( + BSON_TYPE_SAMPLES, + DATE_Y2K, + OID_EPOCH, + TS_EPOCH, + BsonType, +) -# --- Section 1: Constant Literals as Let Values (additional BSON types) --- -# Existing coverage: int, string, bool, double, null, array, document. -# New coverage: Int64, Decimal128, ObjectId, Date, BinData, Timestamp, Regex, MinKey, MaxKey. +# Property [Correlated Subquery — Let Forms]: let variables accept any BSON +# type and aggregation expression as value; type and precision are preserved +# through the sub-pipeline. -_OID = ObjectId("aaaaaaaaaaaaaaaaaaaaaaaa") -_DATE = datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc) -_BIN = Binary(b"\x01\x02\x03") -_TS = Timestamp(1700000000, 1) -_REGEX = Regex("^hello", "i") LOOKUP_LET_CONSTANT_LITERAL_TESTS: list[LookupTestCase] = [ LookupTestCase( @@ -86,7 +88,7 @@ { "$lookup": { "from": FOREIGN, - "let": {"x": _OID}, + "let": {"x": OID_EPOCH}, "pipeline": [{"$addFields": {"val": "$$x", "t": {"$type": "$$x"}}}], "as": "joined", } @@ -95,7 +97,7 @@ expected=[ { "_id": 1, - "joined": [{"_id": 10, "val": _OID, "t": "objectId"}], + "joined": [{"_id": 10, "val": OID_EPOCH, "t": "objectId"}], } ], msg="$lookup let with ObjectId constant should preserve objectId type", @@ -108,7 +110,7 @@ { "$lookup": { "from": FOREIGN, - "let": {"x": _DATE}, + "let": {"x": DATE_Y2K}, "pipeline": [{"$addFields": {"val": "$$x", "t": {"$type": "$$x"}}}], "as": "joined", } @@ -117,7 +119,7 @@ expected=[ { "_id": 1, - "joined": [{"_id": 10, "val": _DATE, "t": "date"}], + "joined": [{"_id": 10, "val": DATE_Y2K, "t": "date"}], } ], msg="$lookup let with ISODate constant should preserve date type", @@ -130,7 +132,7 @@ { "$lookup": { "from": FOREIGN, - "let": {"x": _BIN}, + "let": {"x": BSON_TYPE_SAMPLES[BsonType.BIN_DATA]}, "pipeline": [{"$addFields": {"val": "$$x", "t": {"$type": "$$x"}}}], "as": "joined", } @@ -139,7 +141,9 @@ expected=[ { "_id": 1, - "joined": [{"_id": 10, "val": b"\x01\x02\x03", "t": "binData"}], + "joined": [ + {"_id": 10, "val": BSON_TYPE_SAMPLES[BsonType.BIN_DATA], "t": "binData"} + ], } ], msg="$lookup let with BinData constant should preserve binData type", @@ -152,7 +156,7 @@ { "$lookup": { "from": FOREIGN, - "let": {"x": _TS}, + "let": {"x": TS_EPOCH}, "pipeline": [{"$addFields": {"val": "$$x", "t": {"$type": "$$x"}}}], "as": "joined", } @@ -161,7 +165,7 @@ expected=[ { "_id": 1, - "joined": [{"_id": 10, "val": _TS, "t": "timestamp"}], + "joined": [{"_id": 10, "val": TS_EPOCH, "t": "timestamp"}], } ], msg="$lookup let with Timestamp constant should preserve timestamp type", @@ -174,7 +178,7 @@ { "$lookup": { "from": FOREIGN, - "let": {"x": _REGEX}, + "let": {"x": BSON_TYPE_SAMPLES[BsonType.REGEX]}, "pipeline": [{"$addFields": {"t": {"$type": "$$x"}}}], "as": "joined", } @@ -234,9 +238,6 @@ ), ] -# --- Section 2: Field References as Let Values (advanced traversal) --- -# Existing coverage: simple "$field", "$missing" -# New coverage: nested paths, deep nesting, array field, dotted path through array-of-objects LOOKUP_LET_FIELD_REFERENCE_TESTS: list[LookupTestCase] = [ LookupTestCase( @@ -364,9 +365,6 @@ ), ] -# --- Section 3: Aggregation Expressions as Let Values (beyond basic $add/$concat) --- -# Existing coverage: $add, $concat -# New coverage: $cond, $ifNull, $size, $toUpper, nested expressions, $type, $dateToString LOOKUP_LET_EXPRESSION_TESTS: list[LookupTestCase] = [ LookupTestCase( @@ -490,7 +488,7 @@ ), LookupTestCase( "let_expr_dateToString", - docs=[{"_id": 1, "d": _DATE}], + docs=[{"_id": 1, "d": DATE_Y2K}], foreign_docs=[{"_id": 10}], pipeline=[ { @@ -502,14 +500,11 @@ } } ], - expected=[{"_id": 1, "d": _DATE, "joined": [{"_id": 10, "year": "2024"}]}], + expected=[{"_id": 1, "d": DATE_Y2K, "joined": [{"_id": 10, "year": "2000"}]}], msg="$lookup let with $dateToString expression should format date", ), ] -# --- Section 4: Mixed Forms (multiple variables of different kinds) --- -# Existing coverage: multiple BSON types in one let, expression + field ref -# New coverage: constant + field ref + expression combo, system var + field ref, # multiple expressions referencing same source field LOOKUP_LET_MIXED_FORMS_TESTS: list[LookupTestCase] = [ @@ -736,12 +731,133 @@ ] +LOOKUP_LET_ADDITIONAL_FORMS_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "literal_array_constant", + docs=[{"_id": 1}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"carr": [1, 2, 3]}, + "pipeline": [{"$project": {"_id": 0, "ra": "$$carr"}}], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "joined": [{"ra": [1, 2, 3]}]}], + msg="$lookup let should accept a literal array of constants as a value", + ), + LookupTestCase( + "literal_object_via_dollar_literal", + docs=[{"_id": 1}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"cobj": {"$literal": {"k": 1}}}, + "pipeline": [{"$project": {"_id": 0, "robj": "$$cobj"}}], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "joined": [{"robj": {"k": 1}}]}], + msg="$lookup let should accept an object forced via $literal as a value", + ), + LookupTestCase( + "expr_date_add_value", + docs=[{"_id": 1, "dt": datetime(2020, 1, 1, tzinfo=timezone.utc)}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": { + "d": { + "$dateAdd": { + "startDate": "$dt", + "unit": "day", + "amount": 1, + } + } + }, + "pipeline": [{"$project": {"_id": 0, "rd": "$$d"}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "dt": datetime(2020, 1, 1, tzinfo=timezone.utc), + "joined": [{"rd": datetime(2020, 1, 2, tzinfo=timezone.utc)}], + } + ], + msg=( + "$lookup let should accept a $dateAdd expression value" + " evaluated against the input document" + ), + ), + LookupTestCase( + "expr_map_value", + docs=[{"_id": 1, "vals": [1, 2, 3]}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": { + "arr": { + "$map": { + "input": "$vals", + "as": "e", + "in": {"$multiply": ["$$e", 2]}, + } + } + }, + "pipeline": [{"$project": {"_id": 0, "ra": "$$arr"}}], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "vals": [1, 2, 3], "joined": [{"ra": [2, 4, 6]}]}], + msg=( + "$lookup let should accept a $map expression value" + " evaluated against the input document" + ), + ), + LookupTestCase( + "mixed_form_missing_field_and_literal_no_cross_contamination", + docs=[{"_id": 1}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"c": 42, "m": "$absent"}, + "pipeline": [{"$project": {"_id": 0, "rc": "$$c", "rm": "$$m"}}], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "joined": [{"rc": 42}]}], + msg=( + "$lookup mixed-form let with a missing field and a literal" + " should keep the literal and omit the missing field" + ), + ), +] + + # --- Combine all tests --- LOOKUP_CORRELATED_LET_FORMS_ALL: list[LookupTestCase] = ( LOOKUP_LET_CONSTANT_LITERAL_TESTS + LOOKUP_LET_FIELD_REFERENCE_TESTS + LOOKUP_LET_EXPRESSION_TESTS + LOOKUP_LET_MIXED_FORMS_TESTS + + LOOKUP_LET_ADDITIONAL_FORMS_TESTS ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_match.py b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_correlated_match.py similarity index 75% rename from documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_match.py rename to documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_correlated_match.py index 5d4ccd3c9..912b8da37 100644 --- a/documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_match.py +++ b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_correlated_match.py @@ -22,35 +22,12 @@ from documentdb_tests.framework.executor import execute_command from documentdb_tests.framework.parametrize import pytest_params -# --- Section 1: Comparison Operators with Let Variables in $expr --- +# Property [Correlated Subquery — $match/$expr]: let variables are usable inside +# $match with $expr for all comparison, logical, arithmetic, array, and date +# operators; without $expr the variable reference is a literal string. + LOOKUP_MATCH_COMPARISON_TESTS: list[LookupTestCase] = [ - LookupTestCase( - "match_expr_eq", - docs=[{"_id": 1, "cat": "electronics"}], - foreign_docs=[ - {"_id": 10, "category": "electronics"}, - {"_id": 11, "category": "clothing"}, - ], - pipeline=[ - { - "$lookup": { - "from": FOREIGN, - "let": {"localCat": "$cat"}, - "pipeline": [{"$match": {"$expr": {"$eq": ["$category", "$$localCat"]}}}], - "as": "joined", - } - } - ], - expected=[ - { - "_id": 1, - "cat": "electronics", - "joined": [{"_id": 10, "category": "electronics"}], - } - ], - msg="$lookup $match $expr $eq should match foreign docs where field equals let var", - ), LookupTestCase( "match_expr_ne", docs=[{"_id": 1, "exclude": "inactive"}], @@ -77,33 +54,6 @@ ], msg="$lookup $match $expr $ne should match foreign docs where field differs from let var", ), - LookupTestCase( - "match_expr_gt", - docs=[{"_id": 1, "minScore": 70}], - foreign_docs=[ - {"_id": 10, "score": 80}, - {"_id": 11, "score": 60}, - {"_id": 12, "score": 70}, - ], - pipeline=[ - { - "$lookup": { - "from": FOREIGN, - "let": {"min": "$minScore"}, - "pipeline": [{"$match": {"$expr": {"$gt": ["$score", "$$min"]}}}], - "as": "joined", - } - } - ], - expected=[ - { - "_id": 1, - "minScore": 70, - "joined": [{"_id": 10, "score": 80}], - } - ], - msg="$lookup $match $expr $gt should match foreign docs where field > let var", - ), LookupTestCase( "match_expr_gte", docs=[{"_id": 1, "minScore": 70}], @@ -187,7 +137,6 @@ ), ] -# --- Section 2: Logical Operators Combining Let Variables --- LOOKUP_MATCH_LOGICAL_TESTS: list[LookupTestCase] = [ LookupTestCase( @@ -303,37 +252,8 @@ ), ] -# --- Section 3: Arithmetic in $expr with Let Variables --- LOOKUP_MATCH_ARITHMETIC_TESTS: list[LookupTestCase] = [ - LookupTestCase( - "match_expr_multiply_let_var", - docs=[{"_id": 1, "limit": 25}], - foreign_docs=[ - {"_id": 10, "amount": 60}, - {"_id": 11, "amount": 40}, - ], - pipeline=[ - { - "$lookup": { - "from": FOREIGN, - "let": {"lim": "$limit"}, - "pipeline": [ - {"$match": {"$expr": {"$gt": ["$amount", {"$multiply": ["$$lim", 2]}]}}} - ], - "as": "joined", - } - } - ], - expected=[ - { - "_id": 1, - "limit": 25, - "joined": [{"_id": 10, "amount": 60}], - } - ], - msg="$lookup $match $expr with $multiply on let var should compute threshold correctly", - ), LookupTestCase( "match_expr_add_let_var", docs=[{"_id": 1, "base": 40}], @@ -390,7 +310,6 @@ ), ] -# --- Section 4: Array Operations in $expr with Let Variables --- LOOKUP_MATCH_ARRAY_TESTS: list[LookupTestCase] = [ LookupTestCase( @@ -488,63 +407,8 @@ ), ] -# --- Section 5: Null/Missing Handling in $match $expr --- LOOKUP_MATCH_NULL_MISSING_TESTS: list[LookupTestCase] = [ - LookupTestCase( - "match_expr_eq_let_var_null_matches_null", - docs=[{"_id": 1, "val": None}], - foreign_docs=[ - {"_id": 10, "field": None}, - {"_id": 11, "field": "something"}, - ], - pipeline=[ - { - "$lookup": { - "from": FOREIGN, - "let": {"x": "$val"}, - "pipeline": [{"$match": {"$expr": {"$eq": ["$field", "$$x"]}}}], - "as": "joined", - } - } - ], - expected=[ - { - "_id": 1, - "val": None, - "joined": [{"_id": 10, "field": None}], - } - ], - msg="$lookup $match $expr $eq with null let var should match foreign null fields", - ), - LookupTestCase( - "match_expr_eq_let_var_missing_vs_null", - docs=[{"_id": 1}], - foreign_docs=[ - {"_id": 10, "field": None}, - {"_id": 11, "field": "value"}, - ], - pipeline=[ - { - "$lookup": { - "from": FOREIGN, - "let": {"x": "$nonexistent"}, - "pipeline": [{"$match": {"$expr": {"$eq": ["$field", "$$x"]}}}], - "as": "joined", - } - } - ], - expected=[ - { - "_id": 1, - "joined": [], - } - ], - msg=( - "$lookup $match $expr $eq with missing let var should NOT" - " match foreign null fields (missing != null in $expr $eq)" - ), - ), LookupTestCase( "match_expr_ifNull_wrapping_let_var", docs=[{"_id": 1, "maybe": None}], @@ -575,7 +439,6 @@ ), ] -# --- Section 6: $match Without $expr (literal string behavior) --- LOOKUP_MATCH_WITHOUT_EXPR_TESTS: list[LookupTestCase] = [ LookupTestCase( @@ -675,7 +538,6 @@ ), ] -# --- Section 7: Multiple $match Stages Using Let Variables --- LOOKUP_MATCH_MULTIPLE_STAGES_TESTS: list[LookupTestCase] = [ LookupTestCase( @@ -747,7 +609,6 @@ ), ] -# --- Section 8: Date Comparisons in $expr with Let Variables --- _DATE_CUTOFF = datetime(2024, 6, 1, tzinfo=timezone.utc) _DATE_BEFORE = datetime(2024, 5, 15, tzinfo=timezone.utc) @@ -808,7 +669,6 @@ ), ] -# --- Section 9: Pipeline Cannot Access Outer Fields Directly --- LOOKUP_MATCH_FIELD_ACCESS_TESTS: list[LookupTestCase] = [ LookupTestCase( @@ -843,6 +703,280 @@ ] +LOOKUP_MATCH_ADDITIONAL_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "expr_gt_against_let_var", + docs=[{"_id": 1, "val": 5}], + foreign_docs=[ + {"_id": 10, "ff": 4}, + {"_id": 11, "ff": 5}, + {"_id": 12, "ff": 6}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "$val"}, + "pipeline": [{"$match": {"$expr": {"$gt": ["$ff", "$$x"]}}}], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "val": 5, "joined": [{"_id": 12, "ff": 6}]}], + msg=( + "$lookup sub-pipeline $match $expr $gt should return" + " foreign docs greater than the let var" + ), + ), + LookupTestCase( + "expr_range_with_two_let_vars_on_both_sides", + docs=[{"_id": 1, "lo": 2, "hi": 8}], + foreign_docs=[ + {"_id": 10, "ff": 1}, + {"_id": 11, "ff": 5}, + {"_id": 12, "ff": 9}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"lo": "$lo", "hi": "$hi"}, + "pipeline": [ + { + "$match": { + "$expr": { + "$and": [ + {"$lt": ["$$lo", "$ff"]}, + {"$lt": ["$ff", "$$hi"]}, + ] + } + } + } + ], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "lo": 2, "hi": 8, "joined": [{"_id": 11, "ff": 5}]}], + msg="$lookup sub-pipeline $expr with let vars on both sides should filter a strict range", + ), + LookupTestCase( + "expr_not_in_membership_with_let_array", + docs=[{"_id": 1, "la": [1, 2, 3]}], + foreign_docs=[ + {"_id": 10, "ff": 2}, + {"_id": 11, "ff": 5}, + {"_id": 12, "ff": 1}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"arr": "$la"}, + "pipeline": [{"$match": {"$expr": {"$not": [{"$in": ["$ff", "$$arr"]}]}}}], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "la": [1, 2, 3], "joined": [{"_id": 11, "ff": 5}]}], + msg=( + "$lookup sub-pipeline $expr $not $in should return" + " the complement of the let array membership" + ), + ), + LookupTestCase( + "expr_size_gate_all_or_nothing_per_input_doc", + docs=[ + {"_id": 1, "la": [1, 2, 3]}, + {"_id": 2, "la": [1, 2]}, + ], + foreign_docs=[ + {"_id": 10}, + {"_id": 11}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"arr": "$la"}, + "pipeline": [{"$match": {"$expr": {"$eq": [{"$size": "$$arr"}, 3]}}}], + "as": "joined", + } + } + ], + expected=[ + {"_id": 1, "la": [1, 2, 3], "joined": [{"_id": 10}, {"_id": 11}]}, + {"_id": 2, "la": [1, 2], "joined": []}, + ], + msg=( + "$lookup sub-pipeline $expr $size gate should include" + " all or no foreign docs per input document" + ), + ), + LookupTestCase( + "expr_set_is_subset_with_let_array", + docs=[{"_id": 1, "la": [1, 2]}], + foreign_docs=[ + {"_id": 10, "farr": [1, 2, 3]}, + {"_id": 11, "farr": [1, 4]}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"arr": "$la"}, + "pipeline": [{"$match": {"$expr": {"$setIsSubset": ["$$arr", "$farr"]}}}], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "la": [1, 2], "joined": [{"_id": 10, "farr": [1, 2, 3]}]}], + msg=( + "$lookup sub-pipeline $expr $setIsSubset should return foreign docs" + " whose array is a superset of the let array" + ), + ), + LookupTestCase( + "expr_cond_selects_field_by_let_flag", + docs=[ + {"_id": 1, "flag": True, "target": "m"}, + {"_id": 2, "flag": False, "target": "m"}, + ], + foreign_docs=[ + {"_id": 10, "f1": "m", "f2": "n"}, + {"_id": 11, "f1": "x", "f2": "m"}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"flag": "$flag", "target": "$target"}, + "pipeline": [ + { + "$match": { + "$expr": { + "$eq": [ + { + "$cond": [ + "$$flag", + "$f1", + "$f2", + ] + }, + "$$target", + ] + } + } + } + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "flag": True, + "target": "m", + "joined": [{"_id": 10, "f1": "m", "f2": "n"}], + }, + { + "_id": 2, + "flag": False, + "target": "m", + "joined": [{"_id": 11, "f1": "x", "f2": "m"}], + }, + ], + msg=( + "$lookup sub-pipeline $expr $cond should select the" + " compared field per input-doc let flag" + ), + ), + LookupTestCase( + "expr_switch_branches_keyed_on_let_var", + docs=[ + {"_id": 1, "sel": "hi"}, + {"_id": 2, "sel": "lo"}, + {"_id": 3, "sel": "other"}, + ], + foreign_docs=[ + {"_id": 10, "fnum": 15}, + {"_id": 11, "fnum": 5}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"sel": "$sel"}, + "pipeline": [ + { + "$match": { + "$expr": { + "$switch": { + "branches": [ + { + "case": {"$eq": ["$$sel", "hi"]}, + "then": {"$gt": ["$fnum", 10]}, + }, + { + "case": {"$eq": ["$$sel", "lo"]}, + "then": {"$lt": ["$fnum", 10]}, + }, + ], + "default": False, + } + } + } + } + ], + "as": "joined", + } + } + ], + expected=[ + {"_id": 1, "sel": "hi", "joined": [{"_id": 10, "fnum": 15}]}, + {"_id": 2, "sel": "lo", "joined": [{"_id": 11, "fnum": 5}]}, + {"_id": 3, "sel": "other", "joined": []}, + ], + msg=( + "$lookup sub-pipeline $expr $switch should pick the branch matching" + " the let var and fall through to default" + ), + ), + LookupTestCase( + "expr_array_elem_at_on_let_array", + docs=[{"_id": 1, "la": [7, 8]}], + foreign_docs=[ + {"_id": 10, "ff": 7}, + {"_id": 11, "ff": 8}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"arr": "$la"}, + "pipeline": [ + { + "$match": { + "$expr": { + "$eq": [ + {"$arrayElemAt": ["$$arr", 0]}, + "$ff", + ] + } + } + } + ], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "la": [7, 8], "joined": [{"_id": 10, "ff": 7}]}], + msg="$lookup sub-pipeline $expr $arrayElemAt should access an element of a let-bound array", + ), +] + + # --- Combine all tests --- LOOKUP_CORRELATED_MATCH_ALL: list[LookupTestCase] = ( LOOKUP_MATCH_COMPARISON_TESTS @@ -854,6 +988,7 @@ + LOOKUP_MATCH_MULTIPLE_STAGES_TESTS + LOOKUP_MATCH_DATE_TESTS + LOOKUP_MATCH_FIELD_ACCESS_TESTS + + LOOKUP_MATCH_ADDITIONAL_TESTS ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_nested.py b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_correlated_nested.py similarity index 78% rename from documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_nested.py rename to documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_correlated_nested.py index 53ba5465d..3ae6cb800 100644 --- a/documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_nested.py +++ b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_correlated_nested.py @@ -16,12 +16,14 @@ setup_lookup, ) from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import LET_UNDEFINED_VARIABLE_ERROR from documentdb_tests.framework.executor import execute_command from documentdb_tests.framework.parametrize import pytest_params -# --- Section 1: Multi-Level Let Propagation --- -# Existing coverage: basic propagation (1 level), basic shadow (1 level) -# New coverage: 3-level propagation, outer+inner let coexistence, outer let in inner $match +# Property [Correlated Subquery — Nested Scoping]: let variables propagate into +# nested $lookup sub-pipelines and are correctly shadowed when an inner let +# declares the same name; shadows do not leak to the outer scope. + LOOKUP_NESTED_PROPAGATION_TESTS: list[LookupTestCase] = [ LookupTestCase( @@ -253,8 +255,6 @@ ), ] -# --- Section 2: Let Shadowing in Nested Lookups --- -# Existing coverage: basic inner shadow overwrites outer # New: partial shadow, shadow doesn't leak up, progressive shadow, type change in shadow LOOKUP_NESTED_SHADOW_TESTS: list[LookupTestCase] = [ @@ -463,7 +463,6 @@ ), ] -# --- Section 3: Nested Lookup Structure and Edge Cases --- LOOKUP_NESTED_STRUCTURE_TESTS: list[LookupTestCase] = [ LookupTestCase( @@ -627,9 +626,165 @@ ] +LOOKUP_NESTED_ADDITIONAL_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "sibling_nested_lookups_isolate_same_named_var", + docs=[{"_id": 1}], + foreign_docs=[{"_id": 10, "tag": "p"}, {"_id": 11, "tag": "q"}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "pipeline": [ + {"$match": {"_id": 10}}, + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "p"}, + "pipeline": [{"$match": {"$expr": {"$eq": ["$tag", "$$x"]}}}], + "as": "a", + } + }, + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "q"}, + "pipeline": [{"$match": {"$expr": {"$eq": ["$tag", "$$x"]}}}], + "as": "b", + } + }, + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "joined": [ + { + "_id": 10, + "tag": "p", + "a": [{"_id": 10, "tag": "p"}], + "b": [{"_id": 11, "tag": "q"}], + } + ], + } + ], + msg=( + "$lookup sibling nested $lookups should isolate" + " a same-named let variable to each branch" + ), + ), + LookupTestCase( + "outer_correlated_inner_concise_references_outer_var", + docs=[{"_id": 1, "oval": "a"}], + foreign_docs=[ + {"_id": 10, "lf": "a", "fval": "a"}, + {"_id": 11, "lf": "a", "fval": "b"}, + {"_id": 12, "lf": "c", "fval": "a"}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"a": "$oval"}, + "pipeline": [ + {"$match": {"_id": 10}}, + { + "$lookup": { + "from": FOREIGN, + "localField": "lf", + "foreignField": "lf", + "let": {"outer_a": "$$a"}, + "pipeline": [ + {"$match": {"$expr": {"$eq": ["$fval", "$$outer_a"]}}} + ], + "as": "inner_joined", + } + }, + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "oval": "a", + "joined": [ + { + "_id": 10, + "lf": "a", + "fval": "a", + "inner_joined": [{"_id": 10, "lf": "a", "fval": "a"}], + } + ], + } + ], + msg=( + "$lookup outer correlated form should compose with an inner concise" + " form referencing the outer let variable" + ), + ), + LookupTestCase( + "missing_var_at_one_level_errors", + docs=[{"_id": 1}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"o": "O"}, + "pipeline": [ + { + "$lookup": { + "from": FOREIGN, + "pipeline": [ + { + "$lookup": { + "from": FOREIGN, + "let": {"i": "I"}, + "pipeline": [ + { + "$addFields": { + "combined": { + "$concat": [ + "$$o", + "$$m", + "$$i", + ] + } + } + } + ], + "as": "lvl3", + } + } + ], + "as": "lvl2", + } + } + ], + "as": "joined", + } + } + ], + error_code=LET_UNDEFINED_VARIABLE_ERROR, + msg=( + "$lookup referencing a never-defined variable at a nesting level" + " should error (undefined variable)" + ), + ), +] + + # --- Combine all tests --- LOOKUP_CORRELATED_NESTED_ALL: list[LookupTestCase] = ( - LOOKUP_NESTED_PROPAGATION_TESTS + LOOKUP_NESTED_SHADOW_TESTS + LOOKUP_NESTED_STRUCTURE_TESTS + LOOKUP_NESTED_PROPAGATION_TESTS + + LOOKUP_NESTED_SHADOW_TESTS + + LOOKUP_NESTED_STRUCTURE_TESTS + + LOOKUP_NESTED_ADDITIONAL_TESTS ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_project.py b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_correlated_project.py similarity index 90% rename from documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_project.py rename to documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_correlated_project.py index 191661ee8..63c12b3ab 100644 --- a/documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_project.py +++ b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_correlated_project.py @@ -22,7 +22,10 @@ from documentdb_tests.framework.executor import execute_command from documentdb_tests.framework.parametrize import pytest_params -# --- Section 1: Basic Direct Access in $addFields and $project --- +# Property [Correlated Subquery — Non-Match Stages]: let variables are directly +# accessible in $addFields, $project, $group, $redact, and $replaceRoot without +# requiring $expr wrapping. + LOOKUP_PROJECT_BASIC_TESTS: list[LookupTestCase] = [ LookupTestCase( @@ -110,7 +113,6 @@ ), ] -# --- Section 2: Conditional Logic with Let Variables --- LOOKUP_PROJECT_CONDITIONAL_TESTS: list[LookupTestCase] = [ LookupTestCase( @@ -230,7 +232,6 @@ ), ] -# --- Section 3: Array/String Manipulation with Let Variables --- LOOKUP_PROJECT_ARRAY_STRING_TESTS: list[LookupTestCase] = [ LookupTestCase( @@ -359,7 +360,6 @@ ), ] -# --- Section 4: Let Variables in $group Within Sub-Pipeline --- LOOKUP_PROJECT_GROUP_TESTS: list[LookupTestCase] = [ LookupTestCase( @@ -424,7 +424,6 @@ ), ] -# --- Section 5: Let Variables in $redact --- LOOKUP_PROJECT_REDACT_TESTS: list[LookupTestCase] = [ LookupTestCase( @@ -469,7 +468,6 @@ ), ] -# --- Section 6: Let Variables in $replaceRoot --- LOOKUP_PROJECT_REPLACEROOT_TESTS: list[LookupTestCase] = [ LookupTestCase( @@ -506,7 +504,6 @@ ), ] -# --- Section 7: Type Preservation Through $addFields --- _OID = ObjectId("bbbbbbbbbbbbbbbbbbbbbbbb") _DATE = datetime(2024, 3, 15, 12, 0, 0, tzinfo=timezone.utc) @@ -612,6 +609,63 @@ ] +LOOKUP_PROJECT_ADDITIONAL_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "addfields_let_var_flows_through_project", + docs=[{"_id": 1, "x": "outer"}], + foreign_docs=[{"_id": 10, "ff": "keep"}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "$x"}, + "pipeline": [ + {"$addFields": {"fromOuter": "$$x"}}, + {"$project": {"_id": 1, "fromOuter": 1}}, + ], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "x": "outer", "joined": [{"_id": 10, "fromOuter": "outer"}]}], + msg=( + "$lookup sub-pipeline $addFields of a let variable" + " should flow through a following $project" + ), + ), + LookupTestCase( + "project_reduce_seeded_from_let_var", + docs=[{"_id": 1, "n": 100}], + foreign_docs=[{"_id": 10, "farr": [1, 2, 3]}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"n": "$n"}, + "pipeline": [ + { + "$project": { + "_id": 0, + "reduced": { + "$reduce": { + "input": "$farr", + "initialValue": "$$n", + "in": {"$add": ["$$value", "$$this"]}, + } + }, + } + } + ], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "n": 100, "joined": [{"reduced": 106}]}], + msg="$lookup sub-pipeline $reduce should seed its accumulator from a let variable", + ), +] + + # --- Combine all tests --- LOOKUP_CORRELATED_PROJECT_ALL: list[LookupTestCase] = ( LOOKUP_PROJECT_BASIC_TESTS @@ -621,6 +675,7 @@ + LOOKUP_PROJECT_REDACT_TESTS + LOOKUP_PROJECT_REPLACEROOT_TESTS + LOOKUP_PROJECT_TYPE_PRESERVATION_TESTS + + LOOKUP_PROJECT_ADDITIONAL_TESTS ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_result_semantics.py b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_correlated_result_semantics.py similarity index 97% rename from documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_result_semantics.py rename to documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_correlated_result_semantics.py index a8d931a10..716d2a196 100644 --- a/documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_result_semantics.py +++ b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_correlated_result_semantics.py @@ -19,6 +19,9 @@ from documentdb_tests.framework.executor import execute_command from documentdb_tests.framework.parametrize import pytest_params +# Property [Correlated Subquery — Result Semantics]: the sub-pipeline re-runs +# per outer document producing independent join results; result array cardinality +# and order are controlled by sub-pipeline stages ($sort, $limit, $skip, $project). LOOKUP_RESULT_SEMANTICS_TESTS: list[LookupTestCase] = [ LookupTestCase( "per_doc_variation_distinct_let_values", diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_subquery.py b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_correlated_subquery.py similarity index 90% rename from documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_subquery.py rename to documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_correlated_subquery.py index fcb2d3d5b..c6cd57ffe 100644 --- a/documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_subquery.py +++ b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_correlated_subquery.py @@ -11,7 +11,6 @@ setup_lookup, ) from documentdb_tests.framework.assertions import assertResult -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 @@ -542,65 +541,9 @@ expected=[{"_id": 1, "joined": [{"_id": 10, "now_type": "date"}]}], msg="$lookup should accept system variable $$NOW as a let value producing a date type", ), - LookupTestCase( - "remove_as_let_value_treats_variable_as_missing", - docs=[{"_id": 1}], - foreign_docs=[{"_id": 10}], - pipeline=[ - { - "$lookup": { - "from": FOREIGN, - "let": {"removed": "$$REMOVE"}, - "pipeline": [ - { - "$addFields": { - "removed_val": "$$removed", - "type_result": {"$type": "$$removed"}, - } - } - ], - "as": "joined", - } - } - ], - expected=[ - { - "_id": 1, - "joined": [{"_id": 10, "type_result": "missing"}], - }, - ], - msg=( - "$lookup with $$REMOVE as a let value should cause the" - " variable to be treated as a removed/missing field" - ), - ), -] - -# Property [Correlated Subquery Expression Error]: expression evaluation -# errors in let values propagate as errors. -LOOKUP_CORRELATED_SUBQUERY_ERROR_TESTS: list[LookupTestCase] = [ - LookupTestCase( - "let_expression_error_propagates", - docs=[{"_id": 1}], - foreign_docs=[{"_id": 10}], - pipeline=[ - { - "$lookup": { - "from": FOREIGN, - "let": {"bad": {"$divide": [1, 0]}}, - "pipeline": [], - "as": "joined", - } - } - ], - error_code=BAD_VALUE_ERROR, - msg="$lookup should propagate expression evaluation errors in let values", - ), ] -LOOKUP_CORRELATED_SUBQUERY_ALL_TESTS: list[LookupTestCase] = ( - LOOKUP_CORRELATED_SUBQUERY_TESTS + LOOKUP_CORRELATED_SUBQUERY_ERROR_TESTS -) +LOOKUP_CORRELATED_SUBQUERY_ALL_TESTS: list[LookupTestCase] = LOOKUP_CORRELATED_SUBQUERY_TESTS @pytest.mark.aggregate diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_system_vars.py b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_correlated_system_vars.py similarity index 96% rename from documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_system_vars.py rename to documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_correlated_system_vars.py index ac100d517..48da144cf 100644 --- a/documentdb_tests/compatibility/tests/core/operator/stages/lookup/correlated/test_lookup_correlated_system_vars.py +++ b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_correlated_system_vars.py @@ -18,6 +18,9 @@ from documentdb_tests.framework.executor import execute_command from documentdb_tests.framework.parametrize import pytest_params +# Property [Correlated Subquery — System Variables]: inside the sub-pipeline +# $$ROOT and $$CURRENT refer to the foreign document context; outer context must +# be captured via let before the sub-pipeline executes. LOOKUP_SYSTEM_VARS_TESTS: list[LookupTestCase] = [ LookupTestCase( "ROOT_inside_sub_pipeline_refers_to_foreign", From af1cbb5efa75ee5ca50b385c1dfbdac8c0b1bdc2 Mon Sep 17 00:00:00 2001 From: PatersonProjects Date: Thu, 9 Jul 2026 14:22:25 -0700 Subject: [PATCH 4/4] Fixed broken test Signed-off-by: PatersonProjects --- .../stages/lookup/test_lookup_correlated_let_forms.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_correlated_let_forms.py b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_correlated_let_forms.py index eecd6618a..031acd253 100644 --- a/documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_correlated_let_forms.py +++ b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_correlated_let_forms.py @@ -11,7 +11,7 @@ from datetime import datetime, timezone import pytest -from bson import Decimal128, Int64, MaxKey, MinKey +from bson import Binary, Decimal128, Int64, MaxKey, MinKey from documentdb_tests.compatibility.tests.core.operator.stages.lookup.utils.lookup_common import ( FOREIGN, @@ -132,7 +132,7 @@ { "$lookup": { "from": FOREIGN, - "let": {"x": BSON_TYPE_SAMPLES[BsonType.BIN_DATA]}, + "let": {"x": Binary(b"\x00\x01\x02", 128)}, "pipeline": [{"$addFields": {"val": "$$x", "t": {"$type": "$$x"}}}], "as": "joined", } @@ -141,9 +141,7 @@ expected=[ { "_id": 1, - "joined": [ - {"_id": 10, "val": BSON_TYPE_SAMPLES[BsonType.BIN_DATA], "t": "binData"} - ], + "joined": [{"_id": 10, "val": Binary(b"\x00\x01\x02", 128), "t": "binData"}], } ], msg="$lookup let with BinData constant should preserve binData type",