diff --git a/benchmark_encoder.py b/benchmark_encoder.py new file mode 100644 index 000000000..c50e75fa8 --- /dev/null +++ b/benchmark_encoder.py @@ -0,0 +1,60 @@ +import time + +from fastapi.encoders import jsonable_encoder +from pydantic import BaseModel + + +class SubModel(BaseModel): + name: str + value: int = 42 + + +class MainModel(BaseModel): + id: int + title: str + sub: SubModel + items: list[SubModel] + maybe: str | None = None + + +def run_benchmark(): + sub = SubModel(name="test") + model = MainModel( + id=1, + title="hello", + sub=sub, + items=[sub] * 50, # 50 items to have a decent dictionary size + ) + + iterations = 20000 + print(f"Benchmarking jsonable_encoder over {iterations} iterations...") + + # 1. Optimized Path (Direct return) + # Warmup + for _ in range(100): + jsonable_encoder(model) + + start_time = time.perf_counter() + for _ in range(iterations): + jsonable_encoder(model) + optimized_time = time.perf_counter() - start_time + + # 2. Original Path (Double serialization via model_dump + recursive dict encoding) + # Warmup + for _ in range(100): + jsonable_encoder(model.model_dump(mode="json")) + + start_time = time.perf_counter() + for _ in range(iterations): + # We simulate the exact old logic: model_dump(mode="json") followed by recursive jsonable_encoder + obj_dict = model.model_dump(mode="json") + jsonable_encoder(obj_dict) + original_time = time.perf_counter() - start_time + + print(f"Original Code Path: {original_time:.4f} seconds") + print(f"Optimized Code Path: {optimized_time:.4f} seconds") + print(f"Speedup: {original_time / optimized_time:.2f}x") + + +if __name__ == "__main__": + run_benchmark() diff --git a/fastapi/encoders.py b/fastapi/encoders.py index c9f882d2b..741bcf7fe 100644 --- a/fastapi/encoders.py +++ b/fastapi/encoders.py @@ -241,6 +241,24 @@ def jsonable_encoder( if exclude is not None and not isinstance(exclude, (set, dict)): exclude = set(exclude) # type: ignore[assignment] # ty: ignore[invalid-assignment] if isinstance(obj, BaseModel): + if not exclude_none and not exclude_defaults: + obj_dict = obj.model_dump( + mode="json", + include=include, + exclude=exclude, + by_alias=by_alias, + exclude_unset=exclude_unset, + exclude_none=exclude_none, + exclude_defaults=exclude_defaults, + ) + if not sqlalchemy_safe: + return obj_dict + return { + k: v + for k, v in obj_dict.items() + if not (isinstance(k, str) and k.startswith("_sa")) + } + # Fallback to recursive call for recursive exclude_none/exclude_defaults cleanup obj_dict = obj.model_dump( mode="json", include=include, diff --git a/tests/test_encoder_performance.py b/tests/test_encoder_performance.py new file mode 100644 index 000000000..aba3b7805 --- /dev/null +++ b/tests/test_encoder_performance.py @@ -0,0 +1,51 @@ +from fastapi.encoders import jsonable_encoder +from pydantic import BaseModel + + +class SubModel(BaseModel): + name: str + value: int = 42 + + +class MainModel(BaseModel): + id: int + title: str + sub: SubModel + items: list[SubModel] + maybe: str | None = None + _sa_instance_state: str = "should-be-removed" + + +def test_basemodel_serialization_correctness(): + sub = SubModel(name="test") + model = MainModel( + id=1, + title="hello", + sub=sub, + items=[sub, SubModel(name="another", value=10)], + ) + + # 1. Standard serialization + encoded = jsonable_encoder(model) + assert encoded == { + "id": 1, + "title": "hello", + "sub": {"name": "test", "value": 42}, + "items": [ + {"name": "test", "value": 42}, + {"name": "another", "value": 10}, + ], + "maybe": None, + } + + # 2. Exclude none + encoded_exclude_none = jsonable_encoder(model, exclude_none=True) + assert "maybe" not in encoded_exclude_none + + # 3. Include and Exclude parameter filtering + encoded_filtered = jsonable_encoder(model, include={"id", "title"}) + assert encoded_filtered == {"id": 1, "title": "hello"} + + encoded_excluded = jsonable_encoder(model, exclude={"sub", "items"}) + assert "sub" not in encoded_excluded + assert "items" not in encoded_excluded diff --git a/tests/test_jsonable_encoder.py b/tests/test_jsonable_encoder.py index 8f8bd3fcb..d037acf7b 100644 --- a/tests/test_jsonable_encoder.py +++ b/tests/test_jsonable_encoder.py @@ -329,3 +329,65 @@ def test_encode_color(module_path): data = {"color": Color("blue")} assert jsonable_encoder(data) == {"color": "blue"} + + +def test_jsonable_encoder_sqlalchemy_safe_base_model(): + class Model(BaseModel): + sa_key: str = Field(alias="_sa_key") + normal_key: str + + model = Model(_sa_key="foo", normal_key="bar") + + # Test case 1: sqlalchemy_safe=True (default) and no exclude_none/exclude_defaults + assert jsonable_encoder(model, by_alias=True) == {"normal_key": "bar"} + + # Test case 2: sqlalchemy_safe=False and no exclude_none/exclude_defaults + # This hits: "if not sqlalchemy_safe: return obj_dict" + assert jsonable_encoder(model, by_alias=True, sqlalchemy_safe=False) == { + "_sa_key": "foo", + "normal_key": "bar", + } + + # Test case 3: sqlalchemy_safe=True (default) and exclude_none=True + assert jsonable_encoder(model, by_alias=True, exclude_none=True) == { + "normal_key": "bar" + } + + # Test case 4: sqlalchemy_safe=False and exclude_none=True + assert jsonable_encoder( + model, by_alias=True, exclude_none=True, sqlalchemy_safe=False + ) == { + "_sa_key": "foo", + "normal_key": "bar", + } + + +def test_jsonable_encoder_sqlalchemy_safe_dict(): + data = {"_sa_key": "foo", "normal_key": "bar"} + + # Test case 5: dict with sqlalchemy_safe=True (default) + assert jsonable_encoder(data) == {"normal_key": "bar"} + + # Test case 6: dict with sqlalchemy_safe=False + assert jsonable_encoder(data, sqlalchemy_safe=False) == { + "_sa_key": "foo", + "normal_key": "bar", + } + + +def test_jsonable_encoder_sqlalchemy_safe_dataclass(): + @dataclass + class DataclassItem: + normal_key: str + _sa_key: str + + item = DataclassItem(normal_key="bar", _sa_key="foo") + + # Test case 7: dataclass with sqlalchemy_safe=True (default) + assert jsonable_encoder(item) == {"normal_key": "bar"} + + # Test case 8: dataclass with sqlalchemy_safe=False + assert jsonable_encoder(item, sqlalchemy_safe=False) == { + "_sa_key": "foo", + "normal_key": "bar", + }