Repository: litestar-org/litestar

Issue:
Bug: Renaming in DTOs with nested fields will rename all occurrences.
### Description

In the example below, we have two models: 'Chat' and 'Message', each with an 'id' field. We also define a ResponseDTO for the Chat model, using DTOConfig to rename its 'id' field to 'chat_id'. 

The intention is to only rename the 'id' of the Chat model. However, this configuration unexpectedly renames the 'id' field in the nested Message objects as well. As a result, the response output is 

`{"chat_id":"1","messages":[{"chat_id":"1","created_at":"1","user_id":"1"}]}`, 

where both Chat and Message 'id' fields are affected, contrary to the intended behavior.


### URL to code causing the issue

_No response_

### MCVE

```python
from typing_extensions import List
from litestar import get, Litestar
from pydantic import BaseModel
from litestar.dto import DTOConfig
from litestar.contrib.pydantic import PydanticDTO

class Message(BaseModel):
    id: str
    created_at: str
    user_id: str

class Chat(BaseModel):
    id: str
    messages: List[Message]

class ResponseDTO(PydanticDTO[Chat]):
    config = DTOConfig(rename_fields={"id": "chat_id"})

@get(return_dto=ResponseDTO)
async def get_chat() -> Chat:
    return Chat(id="1", messages=[Message(id="1", created_at="1", user_id="1")])

app = Litestar([get_chat])
```


### Steps to reproduce

```bash
1. Start Litestar
2. Make API call `curl localhost:8000`
3. Observe :bug:
```


### Screenshots

_No response_

### Logs

_No response_

### Litestar Version

2.2.1

### Platform

- [X] Linux
- [ ] Mac
- [ ] Windows
- [ ] Other (Please specify in the description above)

<!-- POLAR PLEDGE BADGE START -->
---
> [!NOTE]  
> While we are open for sponsoring on [GitHub Sponsors](https://github.com/sponsors/litestar-org/) and 
> [OpenCollective](https://opencollective.com/litestar), we also utilize [Polar.sh](https://polar.sh/) to engage in pledge-based sponsorship.
>
> Check out all issues funded or available for funding [on our Polar.sh Litestar dashboard](https://polar.sh/litestar-org)
> * If you would like to see an issue prioritized, make a pledge towards it!
> * We receive the pledge once the issue is completed & verified
> * This, along with engagement in the community, helps us know which features are a priority to our users.

<a href="https://polar.sh/litestar-org/litestar/issues/2721">
<picture>
  <source media="(prefers-color-scheme: dark)" srcset="https://polar.sh/api/github/litestar-org/litestar/issues/2721/pledge.svg?darkmode=1">
  <img alt="Fund with Polar" src="https://polar.sh/api/github/litestar-org/litestar/issues/2721/pledge.svg">
</picture>
</a>
<!-- POLAR PLEDGE BADGE END -->



Discussion:
@peterschutt Haven't really investigated yet, but it happens on both the regular and the codegen backend

Resolving patch:
diff --git a/litestar/dto/_backend.py b/litestar/dto/_backend.py
--- a/litestar/dto/_backend.py
+++ b/litestar/dto/_backend.py
@@ -4,7 +4,18 @@
 from __future__ import annotations
 
 from dataclasses import replace
-from typing import TYPE_CHECKING, AbstractSet, Any, Callable, ClassVar, Collection, Final, Mapping, Union, cast
+from typing import (
+    TYPE_CHECKING,
+    AbstractSet,
+    Any,
+    ClassVar,
+    Collection,
+    Final,
+    Mapping,
+    Protocol,
+    Union,
+    cast,
+)
 
 from msgspec import UNSET, Struct, UnsetType, convert, defstruct, field
 from typing_extensions import get_origin
@@ -37,6 +48,19 @@
 __all__ = ("DTOBackend",)
 
 
+class CompositeTypeHandler(Protocol):
+    def __call__(
+        self,
+        field_definition: FieldDefinition,
+        exclude: AbstractSet[str],
+        include: AbstractSet[str],
+        rename_fields: dict[str, str],
+        unique_name: str,
+        nested_depth: int,
+    ) -> CompositeType:
+        ...
+
+
 class DTOBackend:
     __slots__ = (
         "annotation",
@@ -70,7 +94,7 @@ def __init__(
             dto_factory: The DTO factory class calling this backend.
             field_definition: Parsed type.
             handler_id: The name of the handler that this backend is for.
-            is_data_field: Whether or not the field is a subclass of DTOData.
+            is_data_field: Whether the field is a subclass of DTOData.
             model_type: Model type.
             wrapper_attribute_name: If the data that DTO should operate upon is wrapped in a generic datastructure, this is the name of the attribute that the data is stored in.
         """
@@ -82,7 +106,10 @@ def __init__(
         self.wrapper_attribute_name: Final[str | None] = wrapper_attribute_name
 
         self.parsed_field_definitions = self.parse_model(
-            model_type=model_type, exclude=self.dto_factory.config.exclude, include=self.dto_factory.config.include
+            model_type=model_type,
+            exclude=self.dto_factory.config.exclude,
+            include=self.dto_factory.config.include,
+            rename_fields=self.dto_factory.config.rename_fields,
         )
         self.transfer_model_type = self.create_transfer_model_type(
             model_name=model_type.__name__, field_definitions=self.parsed_field_definitions
@@ -99,7 +126,12 @@ def __init__(
         self.annotation = _maybe_wrap_in_generic_annotation(annotation, self.transfer_model_type)
 
     def parse_model(
-        self, model_type: Any, exclude: AbstractSet[str], include: AbstractSet[str], nested_depth: int = 0
+        self,
+        model_type: Any,
+        exclude: AbstractSet[str],
+        include: AbstractSet[str],
+        rename_fields: dict[str, str],
+        nested_depth: int = 0,
     ) -> tuple[TransferDTOFieldDefinition, ...]:
         """Reduce :attr:`model_type` to a tuple :class:`TransferDTOFieldDefinition` instances.
 
@@ -123,6 +155,7 @@ def parse_model(
                     field_definition=field_definition,
                     exclude=exclude,
                     include=include,
+                    rename_fields=rename_fields,
                     field_name=field_definition.name,
                     unique_name=field_definition.model_name,
                     nested_depth=nested_depth,
@@ -130,7 +163,7 @@ def parse_model(
             except RecursionError:
                 continue
 
-            if rename := self.dto_factory.config.rename_fields.get(field_definition.name):
+            if rename := rename_fields.get(field_definition.name):
                 serialization_name = rename
             elif self.dto_factory.config.rename_strategy:
                 serialization_name = _rename_field(
@@ -342,9 +375,7 @@ def encode_data(self, data: Any) -> LitestarEncodableType:
             ),
         )
 
-    def _get_handler_for_field_definition(
-        self, field_definition: FieldDefinition
-    ) -> Callable[[FieldDefinition, AbstractSet[str], AbstractSet[str], str, int], CompositeType] | None:
+    def _get_handler_for_field_definition(self, field_definition: FieldDefinition) -> CompositeTypeHandler | None:
         if field_definition.is_union:
             return self._create_union_type
 
@@ -365,15 +396,24 @@ def _create_transfer_type(
         field_definition: FieldDefinition,
         exclude: AbstractSet[str],
         include: AbstractSet[str],
+        rename_fields: dict[str, str],
         field_name: str,
         unique_name: str,
         nested_depth: int,
     ) -> CompositeType | SimpleType:
         exclude = _filter_nested_field(exclude, field_name)
         include = _filter_nested_field(include, field_name)
+        rename_fields = _filter_nested_field_mapping(rename_fields, field_name)
 
         if composite_type_handler := self._get_handler_for_field_definition(field_definition):
-            return composite_type_handler(field_definition, exclude, include, unique_name, nested_depth)
+            return composite_type_handler(
+                field_definition=field_definition,
+                exclude=exclude,
+                include=include,
+                rename_fields=rename_fields,
+                unique_name=unique_name,
+                nested_depth=nested_depth,
+            )
 
         transfer_model: NestedFieldInfo | None = None
 
@@ -382,7 +422,11 @@ def _create_transfer_type(
                 raise RecursionError
 
             nested_field_definitions = self.parse_model(
-                model_type=field_definition.annotation, exclude=exclude, include=include, nested_depth=nested_depth + 1
+                model_type=field_definition.annotation,
+                exclude=exclude,
+                include=include,
+                rename_fields=rename_fields,
+                nested_depth=nested_depth + 1,
             )
 
             transfer_model = NestedFieldInfo(
@@ -397,6 +441,7 @@ def _create_collection_type(
         field_definition: FieldDefinition,
         exclude: AbstractSet[str],
         include: AbstractSet[str],
+        rename_fields: dict[str, str],
         unique_name: str,
         nested_depth: int,
     ) -> CollectionType:
@@ -408,6 +453,7 @@ def _create_collection_type(
             field_name="0",
             unique_name=f"{unique_name}_0",
             nested_depth=nested_depth,
+            rename_fields=rename_fields,
         )
         return CollectionType(
             field_definition=field_definition, inner_type=inner_type, has_nested=inner_type.has_nested
@@ -418,6 +464,7 @@ def _create_mapping_type(
         field_definition: FieldDefinition,
         exclude: AbstractSet[str],
         include: AbstractSet[str],
+        rename_fields: dict[str, str],
         unique_name: str,
         nested_depth: int,
     ) -> MappingType:
@@ -429,6 +476,7 @@ def _create_mapping_type(
             field_name="0",
             unique_name=f"{unique_name}_0",
             nested_depth=nested_depth,
+            rename_fields=rename_fields,
         )
         value_type = self._create_transfer_type(
             field_definition=inner_types[1] if inner_types else FieldDefinition.from_annotation(Any),
@@ -437,6 +485,7 @@ def _create_mapping_type(
             field_name="1",
             unique_name=f"{unique_name}_1",
             nested_depth=nested_depth,
+            rename_fields=rename_fields,
         )
         return MappingType(
             field_definition=field_definition,
@@ -450,6 +499,7 @@ def _create_tuple_type(
         field_definition: FieldDefinition,
         exclude: AbstractSet[str],
         include: AbstractSet[str],
+        rename_fields: dict[str, str],
         unique_name: str,
         nested_depth: int,
     ) -> TupleType:
@@ -461,6 +511,7 @@ def _create_tuple_type(
                 field_name=str(i),
                 unique_name=f"{unique_name}_{i}",
                 nested_depth=nested_depth,
+                rename_fields=rename_fields,
             )
             for i, inner_type in enumerate(field_definition.inner_types)
         )
@@ -475,6 +526,7 @@ def _create_union_type(
         field_definition: FieldDefinition,
         exclude: AbstractSet[str],
         include: AbstractSet[str],
+        rename_fields: dict[str, str],
         unique_name: str,
         nested_depth: int,
     ) -> UnionType:
@@ -486,6 +538,7 @@ def _create_union_type(
                 field_name=str(i),
                 unique_name=f"{unique_name}_{i}",
                 nested_depth=nested_depth,
+                rename_fields=rename_fields,
             )
             for i, inner_type in enumerate(field_definition.inner_types)
         )
@@ -521,6 +574,15 @@ def _filter_nested_field(field_name_set: AbstractSet[str], field_name: str) -> A
     return {split[1] for s in field_name_set if (split := s.split(".", 1))[0] == field_name and len(split) > 1}
 
 
+def _filter_nested_field_mapping(field_name_mapping: Mapping[str, str], field_name: str) -> dict[str, str]:
+    """Filter a nested field name."""
+    return {
+        split[1]: v
+        for s, v in field_name_mapping.items()
+        if (split := s.split(".", 1))[0] == field_name and len(split) > 1
+    }
+
+
 def _transfer_data(
     destination_type: type[Any],
     source_data: Any | Collection[Any],


Test patch:
diff --git a/tests/unit/test_dto/test_factory/test_backends/test_base_dto.py b/tests/unit/test_dto/test_factory/test_backends/test_base_dto.py
--- a/tests/unit/test_dto/test_factory/test_backends/test_base_dto.py
+++ b/tests/unit/test_dto/test_factory/test_backends/test_base_dto.py
@@ -146,9 +146,16 @@ def create_transfer_type(
     field_name: str = "name",
     unique_name: str = "some_module.SomeModel.name",
     nested_depth: int = 0,
+    rename_fields: dict[str, str] | None = None,
 ) -> TransferType:
     return backend._create_transfer_type(
-        field_definition, exclude or set(), include or set(), field_name, unique_name, nested_depth
+        field_definition=field_definition,
+        exclude=exclude or set(),
+        include=include or set(),
+        field_name=field_name,
+        unique_name=unique_name,
+        nested_depth=nested_depth,
+        rename_fields=rename_fields or {},
     )
 
 
diff --git a/tests/unit/test_dto/test_factory/test_integration.py b/tests/unit/test_dto/test_factory/test_integration.py
--- a/tests/unit/test_dto/test_factory/test_integration.py
+++ b/tests/unit/test_dto/test_factory/test_integration.py
@@ -2,7 +2,8 @@
 from __future__ import annotations
 
 from dataclasses import dataclass, field
-from typing import TYPE_CHECKING, Dict, Generic, List, Optional, Sequence, TypeVar, cast
+from types import ModuleType
+from typing import TYPE_CHECKING, Callable, Dict, Generic, List, Optional, Sequence, TypeVar, cast
 from unittest.mock import MagicMock
 from uuid import UUID
 
@@ -91,6 +92,42 @@ def handler(data: Foo) -> Foo:
         assert response.json() == {"baz": "hello"}
 
 
+def test_renamed_field_nested(use_experimental_dto_backend: bool, create_module: Callable[[str], ModuleType]) -> None:
+    # https://github.com/litestar-org/litestar/issues/2721
+    module = create_module(
+        """
+from dataclasses import dataclass
+from typing import List
+
+@dataclass
+class Bar:
+    id: str
+
+@dataclass
+class Foo:
+    id: str
+    bar: Bar
+    bars: List[Bar]
+"""
+    )
+
+    Foo = module.Foo
+
+    config = DTOConfig(
+        rename_fields={"id": "foo_id", "bar.id": "bar_id", "bars.0.id": "bars_id"},
+        experimental_codegen_backend=use_experimental_dto_backend,
+    )
+    dto = DataclassDTO[Annotated[Foo, config]]  # type: ignore[valid-type]
+
+    @post(dto=dto, signature_types=[Foo])
+    def handler(data: Foo) -> Foo:  # type: ignore[valid-type]
+        return data
+
+    with create_test_client(route_handlers=[handler]) as client:
+        response = client.post("/", json={"foo_id": "1", "bar": {"bar_id": "2"}, "bars": [{"bars_id": "3"}]})
+        assert response.json() == {"foo_id": "1", "bar": {"bar_id": "2"}, "bars": [{"bars_id": "3"}]}
+
+
 @dataclass
 class Spam:
     main_id: str = "spam-id"