-
-
Notifications
You must be signed in to change notification settings - Fork 501
Open
Labels
Bug 🐛This is something that is not working as expectedThis is something that is not working as expected
Description
Description
Using a DTO (with the experimental codegen backend) to serialize a model's optional relationship field(s) when such a field is null leads to a TypeError: Missing required argument for the optional field.
This potentially only occurs when the model being serialized is provided by DI (see MCVE), as the following example which does not use DI to provide the model being serialized works fine with the experimental codegen:
from __future__ import annotations
from typing import TYPE_CHECKING, ClassVar
from sqlalchemy.ext.asyncio import AsyncSession
if TYPE_CHECKING:
pass
import sqlalchemy as sa
from advanced_alchemy.extensions.litestar import (
AsyncSessionConfig,
SQLAlchemyAsyncConfig,
SQLAlchemyDTO,
SQLAlchemyDTOConfig,
SQLAlchemyPlugin,
base,
)
from litestar.app import Litestar
from litestar.handlers import get
from sqlalchemy.orm import mapped_column, relationship
from sqlalchemy.orm.base import Mapped
# ------------------------------------------------------------
# DB Config
# ------------------------------------------------------------
session_config = AsyncSessionConfig(expire_on_commit=False)
alchemy_config = SQLAlchemyAsyncConfig(
connection_string="sqlite+aiosqlite:///:memory:",
before_send_handler="autocommit",
session_config=session_config,
create_all=True,
)
alchemy = SQLAlchemyPlugin(config=alchemy_config)
# ------------------------------------------------------------
# User and Group models
# ------------------------------------------------------------
class User(base.BigIntBase):
__tablename__ = "user"
name: Mapped[str] = mapped_column(sa.String())
group_id: Mapped[int | None] = mapped_column(sa.ForeignKey("group.id"), nullable=True)
group: Mapped["GroupMembership | None"] = relationship(back_populates="user")
class GroupMembership(base.BigIntBase):
__tablename__ = "group"
group_name: Mapped[str] = mapped_column(sa.String())
user: Mapped[User | None] = relationship(back_populates="group")
# ------------------------------------------------------------
# User DTO and read route
# ------------------------------------------------------------
class UserRead(SQLAlchemyDTO[User]):
config: ClassVar[SQLAlchemyDTOConfig] = SQLAlchemyDTOConfig()
@get(path="/user", return_dto=UserRead)
async def get_user(db_session: AsyncSession) -> User:
user = await db_session.get(User, 1)
return user
# ------------------------------------------------------------
# App
# ------------------------------------------------------------
async def on_startup(app: Litestar) -> None:
async with alchemy_config.get_session() as db_session:
db_session.add(User(name="User 1"))
await db_session.commit()
app = Litestar([get_user], plugins=[alchemy], on_startup=[on_startup], debug=True)➜ localhost:8000/user
{"name":"User 1","group_id":null,"group":null,"id":1}%
URL to code causing the issue
No response
MCVE
from __future__ import annotations
from typing import TYPE_CHECKING, Any, ClassVar
if TYPE_CHECKING:
from litestar.types import Dependencies
import sqlalchemy as sa
from advanced_alchemy.extensions.litestar import (
AsyncSessionConfig,
SQLAlchemyAsyncConfig,
SQLAlchemyDTO,
SQLAlchemyDTOConfig,
SQLAlchemyPlugin,
base,
)
from advanced_alchemy.extensions.litestar.providers import create_service_provider
from advanced_alchemy.repository import SQLAlchemyAsyncRepository
from advanced_alchemy.service import SQLAlchemyAsyncRepositoryService
from litestar import Controller, Request
from litestar.app import Litestar
from litestar.connection import ASGIConnection
from litestar.di import Provide
from litestar.handlers import get
from litestar.response import Response
from litestar.security.jwt import JWTCookieAuth, Token
from sqlalchemy.orm import joinedload, mapped_column, relationship
from sqlalchemy.orm.base import Mapped
session_config = AsyncSessionConfig(expire_on_commit=False)
alchemy_config = SQLAlchemyAsyncConfig(
connection_string="sqlite+aiosqlite:///:memory:",
before_send_handler="autocommit",
session_config=session_config,
create_all=True,
)
alchemy = SQLAlchemyPlugin(config=alchemy_config)
# ------------------------------------------------------------
# User and Group Model and Service
# ------------------------------------------------------------
class User(base.BigIntBase):
__tablename__ = "user"
name: Mapped[str] = mapped_column(sa.String())
group: Mapped["GroupMembership | None"] = relationship(back_populates="user")
class GroupMembership(base.BigIntBase):
__tablename__ = "group"
group_name: Mapped[str] = mapped_column(sa.String())
user_id: Mapped[int] = mapped_column(sa.ForeignKey("user.id"))
user: Mapped[User | None] = relationship(back_populates="group")
class UserService(SQLAlchemyAsyncRepositoryService[User]):
class UserRepository(SQLAlchemyAsyncRepository[User]):
model_type: type[User] = User
repository_type: type[UserRepository] = UserRepository
# ------------------------------------------------------------
# Dependencies
# ------------------------------------------------------------
provide_user_service = create_service_provider(
UserService,
load=[joinedload(User.group)]
)
async def current_user_dependency(request: Request[User, Any, Any]) -> User:
return request.user
# ------------------------------------------------------------
# DTO and User Controller
# ------------------------------------------------------------
class UserRead(SQLAlchemyDTO[User]):
config: ClassVar[SQLAlchemyDTOConfig] = SQLAlchemyDTOConfig()
class UserController(Controller):
dependencies: Dependencies | None = {
"user_service": Provide(provide_user_service),
"current_user": Provide(current_user_dependency),
}
path: str = "/user"
@get(path="/me", return_dto=UserRead)
async def get_user(self, current_user: User) -> User:
return current_user
class LoginController(Controller):
dependencies: Dependencies | None = {"user_service": Provide(provide_user_service)}
path = "/login"
@get(path="/", exclude_from_auth=True)
async def login(self, user_service: UserService) -> Response[dict[str, str]]:
user = await user_service.get_one(id=1)
return auth.login(identifier=str(user.id), response_body={"message": "logged in"})
# ------------------------------------------------------------
# Guards
# -----------------------------------------------------------
async def get_current_user_from_token(token: Token, connection: ASGIConnection) -> User | None:
service_provider = provide_user_service(
alchemy_config.provide_session(connection.app.state, connection.scope)
)
async for service in service_provider:
user = await service.get_one_or_none(id=int(token.sub))
return user if user else None
return None
auth = JWTCookieAuth[User](
retrieve_user_handler=get_current_user_from_token,
token_secret="secret",
exclude=["/login"],
)
# ------------------------------------------------------------
# App
# -----------------------------------------------------------
async def on_startup(app: Litestar) -> None:
async with alchemy_config.get_session() as db_session:
db_session.add(User(name="User 1"))
await db_session.commit()
app = Litestar(
[UserController, LoginController],
plugins=[alchemy],
on_startup=[on_startup],
debug=True,
on_app_init=[auth.on_app_init],
)Steps to reproduce
- Save the MCVE as
app.py - Run the following test script:
from litestar.testing import TestClient
from app import app
with TestClient(app) as client:
login_response = client.get("/login")
print(f"Login: {login_response.status_code}")
me_response = client.get("/user/me")
print(f"/user/me body: {me_response.text}")- You will see the server raise a
TypeError: Missing required argument 'group'error corresponding to the nullgroupfield for the User (since no group has been created in the database and assigned to the User). - Disable the experimental_codegen_backend in the UserRead DTO (
experimental_codegen_backend=False) - Re-run the test script
- You will see the server correctly respond with the User model (with a null group field):
➜ litestar-dto-mre uv run test_mre.py
Login: 201
INFO - 2025-11-28 22:21:47,370 - httpx - _client - HTTP Request: GET http://testserver.local/login "HTTP/1.1 201 Created"
/user/me body: {"name":"User 1","group":null,"id":1}
INFO - 2025-11-28 22:21:47,372 - httpx - _client - HTTP Request: GET http://testserver.local/user/me "HTTP/1.1 200 OK"
Screenshots
No response
Logs
➜ uv run test_mre.py
Login: 201
INFO - 2025-11-28 21:54:17,504 - httpx - _client - HTTP Request: GET http://testserver.local/login "HTTP/1.1 201 Created"
ERROR - 2025-11-28 21:54:17,507 - litestar - config - Uncaught exception (connection_type=http, path='/user/me'):
Traceback (most recent call last):
File "/Users/colinsmall/GitHub/litestar-dto-mre/.venv/lib/python3.13/site-packages/litestar/middleware/_internal/exceptions/middleware.py", line 158, in __call__
await self.app(scope, receive, capture_response_started)
File "/Users/colinsmall/GitHub/litestar-dto-mre/.venv/lib/python3.13/site-packages/litestar/routes/http.py", line 81, in handle
response = await self._get_response_for_request(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
scope=scope, request=request, route_handler=route_handler, parameter_model=parameter_model
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
)
^
File "/Users/colinsmall/GitHub/litestar-dto-mre/.venv/lib/python3.13/site-packages/litestar/routes/http.py", line 133, in _get_response_for_request
return await self._call_handler_function(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
scope=scope, request=request, parameter_model=parameter_model, route_handler=route_handler
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
)
^
File "/Users/colinsmall/GitHub/litestar-dto-mre/.venv/lib/python3.13/site-packages/litestar/routes/http.py", line 184, in _call_handler_function
return await route_handler.to_response(app=scope["litestar_app"], data=response_data, request=request)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/colinsmall/GitHub/litestar-dto-mre/.venv/lib/python3.13/site-packages/litestar/handlers/http_handlers/base.py", line 581, in to_response
data = return_dto_type(request).data_to_encodable_type(data)
File "/Users/colinsmall/GitHub/litestar-dto-mre/.venv/lib/python3.13/site-packages/litestar/dto/base_dto.py", line 124, in data_to_encodable_type
return backend.encode_data(data)
~~~~~~~~~~~~~~~~~~~^^^^^^
File "/Users/colinsmall/GitHub/litestar-dto-mre/.venv/lib/python3.13/site-packages/litestar/dto/_codegen_backend.py", line 153, in encode_data
return cast("LitestarEncodableType", self._encode_data(data))
~~~~~~~~~~~~~~~~~^^^^^^
File "dto_transfer_function_fb84194f689f", line 83, in func
tmp_return_type_0 = destination_type_0(**unstructured_data_0)
TypeError: Missing required argument 'group'
/user/me: 500
/user/me body: Traceback (most recent call last):
File "/Users/colinsmall/GitHub/litestar-dto-mre/.venv/lib/python3.13/site-packages/litestar/middleware/_internal/exceptions/middleware.py", line 158, in __call__
await self.app(scope, receive, capture_response_started)
File "/Users/colinsmall/GitHub/litestar-dto-mre/.venv/lib/python3.13/site-packages/litestar/routes/http.py", line 81, in handle
response = await self._get_response_for_request(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
scope=scope, request=request, route_handler=route_handler, parameter_model=parameter_model
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
)
^
File "/Users/colinsmall/GitHub/litestar-dto-mre/.venv/lib/python3.13/site-packages/litestar/routes/http.py", line 133, in _get_response_for_request
return await self._call_handler_function(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
scope=scope, request=request, parameter_model=parameter_model, route_handler=route_handler
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
)
^
File "/Users/colinsmall/GitHub/litestar-dto-mre/.venv/lib/python3.13/site-packages/litestar/routes/http.py", line 184, in _call_handler_function
return await route_handler.to_response(app=scope["litestar_app"], data=response_data, request=request)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/colinsmall/GitHub/litestar-dto-mre/.venv/lib/python3.13/site-packages/litestar/handlers/http_handlers/base.py", line 581, in to_response
data = return_dto_type(request).data_to_encodable_type(data)
File "/Users/colinsmall/GitHub/litestar-dto-mre/.venv/lib/python3.13/site-packages/litestar/dto/base_dto.py", line 124, in data_to_encodable_type
return backend.encode_data(data)
~~~~~~~~~~~~~~~~~~~^^^^^^
File "/Users/colinsmall/GitHub/litestar-dto-mre/.venv/lib/python3.13/site-packages/litestar/dto/_codegen_backend.py", line 153, in encode_data
return cast("LitestarEncodableType", self._encode_data(data))
~~~~~~~~~~~~~~~~~^^^^^^
File "dto_transfer_function_fb84194f689f", line 83, in func
tmp_return_type_0 = destination_type_0(**unstructured_data_0)
TypeError: Missing required argument 'group'
INFO - 2025-11-28 21:54:17,509 - httpx - _client - HTTP Request: GET http://testserver.local/user/me "HTTP/1.1 500 Internal Server Error"
Litestar Version
Litestar 2.18.0
Advanced Alchemy 1.8.0
Platform
- Linux
- Mac
- Windows
- Other (Please specify in the description above)
binh-vu and khoa2795
Metadata
Metadata
Assignees
Labels
Bug 🐛This is something that is not working as expectedThis is something that is not working as expected