litestar-org / litestar

Production-ready, Light, Flexible and Extensible ASGI API framework | Effortlessly Build Performant APIs
https://litestar.dev/
MIT License
5.57k stars 379 forks source link

Bug: Generic Controller SQLAlchemyDTO SerializationException: Unsupported type #3666

Closed nchaikh closed 3 months ago

nchaikh commented 3 months ago

Description

I´ve been trying to do a generic controller, something like DRF generic Views. The logic inside works well but the problem is in serialization with the DTO.

URL to code causing the issue

No response

MCVE

from __future__ import annotations

from typing import Generic, TypeVar
import uuid

from litestar import Litestar, get
from litestar.controller import Controller
from litestar.di import Provide
from sqlalchemy import ForeignKey, func, select
from sqlalchemy.orm import (
    Mapped,
    mapped_column,
    relationship,
    DeclarativeBase,
)

from advanced_alchemy.base import UUIDAuditBase, UUIDBase
from advanced_alchemy.extensions.litestar import (
    AsyncSessionConfig,
    SQLAlchemyAsyncConfig,
    SQLAlchemyPlugin,
    async_autocommit_before_send_handler,
    SQLAlchemyDTO,
)
from advanced_alchemy.repository import SQLAlchemyAsyncRepository
from advanced_alchemy.service import SQLAlchemyAsyncRepositoryService

from collections.abc import AsyncGenerator
from datetime import date
from uuid import UUID

from sqlalchemy.ext.asyncio import AsyncSession

Model = TypeVar("Model", bound=DeclarativeBase)
RepositoryService = TypeVar("RepositoryService", bound=SQLAlchemyAsyncRepositoryService)

class GenericController(Controller, Generic[Model, RepositoryService]):
    """Generic controller for all models."""

    @get("/")
    async def list(self, service: RepositoryService) -> list[Model]:
        """List all instances."""
        return await service.list()

    @get("/{id:uuid}")
    async def get(self, service: RepositoryService, id: UUID) -> Model:
        """Get a single instance by ID."""
        return await service.get(id)

# the SQLAlchemy base includes a declarative model for you to use in your models.
# The `Base` class includes a `UUID` based primary key (`id`)
class Author(UUIDBase):
    # we can optionally provide the table name instead of auto-generating it
    __tablename__ = "author"
    name: Mapped[str]
    dob: Mapped[date | None]
    books: Mapped[list[Book]] = relationship(back_populates="author", lazy="noload")

# The `AuditBase` class includes the same UUID` based primary key (`id`) and 2
# additional columns: `created` and `updated`. `created` is a timestamp of when the
# record created, and `updated` is the last time the record was modified.
class Book(UUIDAuditBase):
    __tablename__ = "book"
    title: Mapped[str]
    author_id: Mapped[UUID] = mapped_column(ForeignKey("author.id"))
    author: Mapped[Author] = relationship(lazy="joined", innerjoin=True, viewonly=True)

AuthorDTO = SQLAlchemyDTO[Author]
BookDTO = SQLAlchemyDTO[Book]

class AuthorRepository(SQLAlchemyAsyncRepository[Author]):
    """Author repository."""

    model_type = Author

class AuthorService(SQLAlchemyAsyncRepositoryService[Author]):
    """Author repository."""

    repository_type = AuthorRepository

async def provide_authors_service(
    db_session: AsyncSession,
) -> AsyncGenerator[AuthorService, None]:
    """This provides the default Authors repository."""
    async with AuthorService.new(
        session=db_session,
    ) as service:
        yield service

class AuthorController(GenericController[Author, AuthorService]):
    """Author CRUD"""

    path = "/authors"

    dependencies = {"service": Provide(provide_authors_service)}

    return_dto = AuthorDTO

session_config = AsyncSessionConfig(expire_on_commit=False)
sqlalchemy_config = SQLAlchemyAsyncConfig(
    connection_string="sqlite+aiosqlite:///test.sqlite",
    before_send_handler=async_autocommit_before_send_handler,
    session_config=session_config,
    create_all=True,
)  # Create 'db_session' dependency.
sqlalchemy_plugin = SQLAlchemyPlugin(config=sqlalchemy_config)

async def on_startup() -> None:
    """Adds some dummy data if no data is present."""
    async with sqlalchemy_config.get_session() as session:
        statement = select(func.count()).select_from(Author)
        count = await session.execute(statement)
        if not count.scalar():
            author_id = uuid.uuid4()
            session.add(
                Author(name="Stephen King", dob=date(1954, 9, 21), id=author_id)
            )
            session.add(Book(title="It", author_id=author_id))
            await session.commit()

app = Litestar(
    on_startup=[on_startup],
    route_handlers=[AuthorController],
    plugins=[sqlalchemy_plugin],
)

Steps to reproduce

No response

Screenshots

No response

Logs

litestar\serialization\msgspec_hooks.py", line 164, in encode_json
    raise SerializationException(str(msgspec_error)) from msgspec_error
litestar.exceptions.base_exceptions.SerializationException: Unsupported type: <class 'app.Author'>

Litestar Version

litestar 2.10.0 advanced_alchemy 0.19.0

Platform


[!NOTE]
While we are open for sponsoring on GitHub Sponsors and OpenCollective, we also utilize Polar.sh to engage in pledge-based sponsorship.

Check out all issues funded or available for funding on our Polar.sh dashboard

  • 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.

Fund with Polar

provinzkraut commented 3 months ago

Not a bug, I've described this in more detail in #3667.