From 758ec5e6172a184173371e069994cf9658273f2a Mon Sep 17 00:00:00 2001 From: Mehmet Salih Yavuz Date: Thu, 16 Jul 2026 23:49:23 +0300 Subject: [PATCH] fix: pass codec options to db.command() Database.command() falls back to DEFAULT_CODEC_OPTIONS when codec_options is omitted, so options configured on the client were ignored on every read. Most visibly, uuidRepresentation had no effect: BSON UUID fields were decoded as raw bson.Binary instead of uuid.UUID no matter what the connection string asked for, because reads go through db.command({'find': ...}) rather than collection.find(). Pass the database's codec options for both find and getMore, so results decode consistently with collection.find() and across batches. --- pymongosql/executor.py | 6 ++- pymongosql/result_set.py | 4 +- tests/test_command_codec_options.py | 68 +++++++++++++++++++++++++++++ tests/test_retry.py | 5 ++- 4 files changed, 79 insertions(+), 4 deletions(-) create mode 100644 tests/test_command_codec_options.py diff --git a/pymongosql/executor.py b/pymongosql/executor.py index 80f0650..9d27b65 100644 --- a/pymongosql/executor.py +++ b/pymongosql/executor.py @@ -24,16 +24,18 @@ def _run_db_command(db: Any, command: Dict[str, Any], connection: Any, operation_name: str) -> Dict[str, Any]: """Run a MongoDB command with optional transaction session and retry policy.""" retry_config = getattr(connection, "retry_config", None) + # command() falls back to DEFAULT_CODEC_OPTIONS, ignoring client options like uuidRepresentation + codec_options = db.codec_options if connection and connection.session and connection.session.in_transaction: return execute_with_retry( - lambda: db.command(command, session=connection.session), + lambda: db.command(command, session=connection.session, codec_options=codec_options), retry_config, operation_name, ) return execute_with_retry( - lambda: db.command(command), + lambda: db.command(command, codec_options=codec_options), retry_config, operation_name, ) diff --git a/pymongosql/result_set.py b/pymongosql/result_set.py index d36695c..c1c1e70 100644 --- a/pymongosql/result_set.py +++ b/pymongosql/result_set.py @@ -112,8 +112,10 @@ def _ensure_results_available(self, count: int = 1) -> None: "getMore": self._cursor_id, "collection": self._execution_plan.collection, } + # Decode later batches like the first one, not with DEFAULT_CODEC_OPTIONS + codec_options = self._database.codec_options result = execute_with_retry( - lambda: self._database.command(getmore_cmd), + lambda: self._database.command(getmore_cmd, codec_options=codec_options), self._retry_config, "getMore command", ) diff --git a/tests/test_command_codec_options.py b/tests/test_command_codec_options.py new file mode 100644 index 0000000..2653d6f --- /dev/null +++ b/tests/test_command_codec_options.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +"""Commands must decode with the database's codec options, not DEFAULT_CODEC_OPTIONS.""" +import uuid +from unittest.mock import MagicMock + +from bson.binary import Binary, UuidRepresentation +from bson.codec_options import CodecOptions + +from pymongosql.executor import _run_db_command +from pymongosql.result_set import ResultSet +from pymongosql.sql.query_builder import QueryExecutionPlan + +STANDARD_OPTS = CodecOptions(uuid_representation=UuidRepresentation.STANDARD) + + +def make_db(codec_options=STANDARD_OPTS, command_result=None): + db = MagicMock() + db.codec_options = codec_options + db.command.return_value = command_result if command_result is not None else {"ok": 1} + return db + + +def test_run_db_command_passes_codec_options(): + db = make_db() + connection = MagicMock(retry_config=None, session=None) + + _run_db_command(db, {"find": "users"}, connection, "find") + + assert db.command.call_args.kwargs["codec_options"] is STANDARD_OPTS + + +def test_run_db_command_passes_codec_options_in_transaction(): + db = make_db() + connection = MagicMock(retry_config=None) + connection.session.in_transaction = True + + _run_db_command(db, {"find": "users"}, connection, "find") + + kwargs = db.command.call_args.kwargs + assert kwargs["codec_options"] is STANDARD_OPTS + assert kwargs["session"] is connection.session + + +def test_getmore_passes_codec_options(): + """Ensure a cursor spanning batches decodes every batch the same way""" + db = make_db( + command_result={"cursor": {"id": 0, "nextBatch": [{"_id": 2}]}}, + ) + plan = QueryExecutionPlan(collection="users", projection_stage={"_id": 1}) + result_set = ResultSet( + command_result={"cursor": {"id": 99, "firstBatch": [{"_id": 1}]}}, + execution_plan=plan, + database=db, + ) + + result_set.fetchall() + + assert db.command.called, "getMore was never issued" + assert db.command.call_args.kwargs["codec_options"] is STANDARD_OPTS + + +def test_standard_codec_options_decode_subtype_4_uuid(): + """Check subtype-4 UUIDs decode to uuid.UUID rather than staying Binary""" + value = uuid.UUID("12345678-1234-5678-1234-567812345678") + encoded = Binary.from_uuid(value, UuidRepresentation.STANDARD) + + assert isinstance(encoded.as_uuid(UuidRepresentation.STANDARD), uuid.UUID) + assert STANDARD_OPTS.uuid_representation == UuidRepresentation.STANDARD diff --git a/tests/test_retry.py b/tests/test_retry.py index a65c4ac..92d3bc9 100644 --- a/tests/test_retry.py +++ b/tests/test_retry.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- import pytest +from bson.codec_options import DEFAULT_CODEC_OPTIONS from pymongo.errors import ConnectionFailure, NetworkTimeout from pymongosql.connection import Connection @@ -139,7 +140,9 @@ def test_result_set_getmore_retries_transient_failures(): state = {"calls": 0} class FakeDatabase: - def command(self, command_payload): + codec_options = DEFAULT_CODEC_OPTIONS + + def command(self, command_payload, codec_options=None): state["calls"] += 1 if state["calls"] < 3: raise NetworkTimeout("temporary getMore timeout")