litestar-org / litestar

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

Bug: Models which work in SQLAlchemy+Alembic don't work in Litestar #2556

Closed AgarwalPragy closed 1 year ago

AgarwalPragy commented 1 year ago

Description

I'm porting my app from FastAPI to Litestar.

Models which worked in FastAPI using SQLAlchemy (async) + Alembic no longer work in Litestar

It throws the following error

AttributeError: 'Table' object has no attribute 'created_at'

MCVE

Comparision b/w Alembic and Litestar https://github.com/AgarwalPragy/litestar-bug-report

Sample code for Litestar

from datetime import datetime
from uuid import UUID

from sqlalchemy import BIGINT, Boolean, Index
from sqlalchemy import Uuid, func, TIMESTAMP, String
from sqlalchemy.ext.asyncio import AsyncAttrs
from sqlalchemy.orm import DeclarativeBase, Mapped, declared_attr, mapped_column, relationship

from collections.abc import AsyncGenerator

from advanced_alchemy.extensions.litestar.plugins import SQLAlchemyPlugin
from litestar import Litestar, get
from litestar.contrib.sqlalchemy.plugins import SQLAlchemyAsyncConfig
from litestar.exceptions import ClientException
from litestar.status_codes import HTTP_409_CONFLICT
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession

db_url = 'sqlite+aiosqlite:///db.sqlite'
db_config = SQLAlchemyAsyncConfig(connection_string=db_url)

class ModelBase(AsyncAttrs, DeclarativeBase):
    __abstract__ = True

    type_annotation_map = {
        int: BIGINT,
        datetime: TIMESTAMP(timezone=True),
        str: String,
        UUID: Uuid,
        bool: Boolean,
    }

    id: Mapped[int] = mapped_column(primary_key=True)

    created_at: Mapped[datetime] = mapped_column(
        index=True,
        doc='Time of creation',
        server_default=func.now())
    updated_at: Mapped[datetime] = mapped_column(
        index=True,
        doc='Time of last modification',
        server_default=func.now(),
        onupdate=func.now())

    @declared_attr
    @classmethod
    def __tablename__(cls) -> str:
        """Automatically generate table name from class name."""
        return cls.__name__.lower() + 's'

class GenericItemRevision(ModelBase):
    __abstract__ = True
    target_id: Mapped[UUID] = mapped_column(index=True, nullable=False)
    data: Mapped[str]

class RevisionsMixin:
    @declared_attr
    @classmethod
    def revisions(cls) -> Mapped[list[GenericItemRevision]]:
        revision_class_name = f"{cls.__name__}Revision"
        revision_table_name = cls.__name__.lower() + '_revisions'

        class Revision(GenericItemRevision):
            __tablename__ = revision_table_name
            __table_args__ = (
                Index(f'ix_{revision_table_name}_target_id_created_at', 'target_id', 'created_at'),
            )

        Revision.__name__ = revision_class_name
        cls.RevisionModel = Revision
        return relationship(revision_class_name,
                            order_by=f'desc({revision_table_name}.created_at)',
                            back_populates="target")

class Note(RevisionsMixin, ModelBase):
    contents: Mapped[str]

async def provide_transaction(db_session: AsyncSession) -> AsyncGenerator[AsyncSession, None]:
    try:
        async with db_session.begin():
            yield db_session
    except IntegrityError as exc:
        raise ClientException(
            status_code=HTTP_409_CONFLICT,
            detail=str(exc),
        ) from exc

@get("/")
async def hello(transaction: AsyncSession) -> list[Note]:
    query = select(Note)
    result = await transaction.execute(query)
    return result.scalars().all()

app = Litestar(
    [hello],
    dependencies={"transaction": provide_transaction},
    plugins=[
        SQLAlchemyPlugin(db_config),
    ],
)

Litestar Version

litestar==2.2.1 aiosqlite==0.19.0 alembic==1.12.1 SQLAlchemy==2.0.22

Platform

Logs

(.venv) pragy@pac litestar-sqlalchemy-bug-report % litestar run
Traceback (most recent call last):
  File "litestar-sqlalchemy-bug-report/.venv/bin/litestar", line 8, in <module>
    sys.exit(run_cli())
             ^^^^^^^^^
  File "litestar-sqlalchemy-bug-report/.venv/lib/python3.11/site-packages/litestar/__main__.py", line 6, in run_cli
    litestar_group()
  File "litestar-sqlalchemy-bug-report/.venv/lib/python3.11/site-packages/click/core.py", line 1157, in __call__
    return self.main(*args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "litestar-sqlalchemy-bug-report/.venv/lib/python3.11/site-packages/rich_click/rich_command.py", line 125, in main
    with self.make_context(prog_name, args, **extra) as ctx:
         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "litestar-sqlalchemy-bug-report/.venv/lib/python3.11/site-packages/litestar/cli/_utils.py", line 242, in make_context
    self._prepare(ctx)
  File "litestar-sqlalchemy-bug-report/.venv/lib/python3.11/site-packages/litestar/cli/_utils.py", line 224, in _prepare
    env = ctx.obj = LitestarEnv.from_env(ctx.params.get("app_path"), ctx.params.get("app_dir"))
                    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "litestar-sqlalchemy-bug-report/.venv/lib/python3.11/site-packages/litestar/cli/_utils.py", line 122, in from_env
    loaded_app = _autodiscover_app(cwd)
                 ^^^^^^^^^^^^^^^^^^^^^^
  File "litestar-sqlalchemy-bug-report/.venv/lib/python3.11/site-packages/litestar/cli/_utils.py", line 332, in _autodiscover_app
    module = importlib.import_module(import_path)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/importlib/__init__.py", line 126, in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "<frozen importlib._bootstrap>", line 1204, in _gcd_import
  File "<frozen importlib._bootstrap>", line 1176, in _find_and_load
  File "<frozen importlib._bootstrap>", line 1147, in _find_and_load_unlocked
  File "<frozen importlib._bootstrap>", line 690, in _load_unlocked
  File "<frozen importlib._bootstrap_external>", line 940, in exec_module
  File "<frozen importlib._bootstrap>", line 241, in _call_with_frames_removed
  File "litestar-sqlalchemy-bug-report/app.py", line 35, in <module>
    app = Litestar(
          ^^^^^^^^^
  File "litestar-sqlalchemy-bug-report/.venv/lib/python3.11/site-packages/litestar/app.py", line 442, in __init__
    self.register(route_handler)
  File "litestar-sqlalchemy-bug-report/.venv/lib/python3.11/site-packages/litestar/app.py", line 618, in register
    route_handler.on_registration(self)
  File "litestar-sqlalchemy-bug-report/.venv/lib/python3.11/site-packages/litestar/handlers/http_handlers/base.py", line 492, in on_registration
    super().on_registration(app)
  File "litestar-sqlalchemy-bug-report/.venv/lib/python3.11/site-packages/litestar/handlers/base.py", line 521, in on_registration
    self.resolve_return_dto()
  File "litestar-sqlalchemy-bug-report/.venv/lib/python3.11/site-packages/litestar/handlers/base.py", line 480, in resolve_return_dto
    return_dto.create_for_field_definition(
  File "litestar-sqlalchemy-bug-report/.venv/lib/python3.11/site-packages/litestar/dto/base_dto.py", line 185, in create_for_field_definition
    backend_context[key] = backend_cls(  # type: ignore[literal-required]
                           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "litestar-sqlalchemy-bug-report/.venv/lib/python3.11/site-packages/litestar/dto/_backend.py", line 84, in __init__
    self.parsed_field_definitions = self.parse_model(
                                    ^^^^^^^^^^^^^^^^^
  File "litestar-sqlalchemy-bug-report/.venv/lib/python3.11/site-packages/litestar/dto/_backend.py", line 111, in parse_model
    for field_definition in self.dto_factory.generate_field_definitions(model_type):
  File "litestar-sqlalchemy-bug-report/.venv/lib/python3.11/site-packages/advanced_alchemy/extensions/litestar/dto.py", line 234, in generate_field_definitions
    for composite_property in mapper.composites:
                              ^^^^^^^^^^^^^^^^^
  File "litestar-sqlalchemy-bug-report/.venv/lib/python3.11/site-packages/sqlalchemy/util/langhelpers.py", line 1253, in __get__
    obj.__dict__[self.__name__] = result = self.fget(obj)
                                           ^^^^^^^^^^^^^^
  File "litestar-sqlalchemy-bug-report/.venv/lib/python3.11/site-packages/sqlalchemy/orm/mapper.py", line 3201, in composites
    return self._filter_properties(
           ^^^^^^^^^^^^^^^^^^^^^^^^
  File "litestar-sqlalchemy-bug-report/.venv/lib/python3.11/site-packages/sqlalchemy/orm/mapper.py", line 3208, in _filter_properties
    self._check_configure()
  File "litestar-sqlalchemy-bug-report/.venv/lib/python3.11/site-packages/sqlalchemy/orm/mapper.py", line 2386, in _check_configure
    _configure_registries({self.registry}, cascade=True)
  File "litestar-sqlalchemy-bug-report/.venv/lib/python3.11/site-packages/sqlalchemy/orm/mapper.py", line 4199, in _configure_registries
    _do_configure_registries(registries, cascade)
  File "litestar-sqlalchemy-bug-report/.venv/lib/python3.11/site-packages/sqlalchemy/orm/mapper.py", line 4240, in _do_configure_registries
    mapper._post_configure_properties()
  File "litestar-sqlalchemy-bug-report/.venv/lib/python3.11/site-packages/sqlalchemy/orm/mapper.py", line 2403, in _post_configure_properties
    prop.init()
  File "litestar-sqlalchemy-bug-report/.venv/lib/python3.11/site-packages/sqlalchemy/orm/interfaces.py", line 579, in init
    self.do_init()
  File "litestar-sqlalchemy-bug-report/.venv/lib/python3.11/site-packages/sqlalchemy/orm/relationships.py", line 1634, in do_init
    self._process_dependent_arguments()
  File "litestar-sqlalchemy-bug-report/.venv/lib/python3.11/site-packages/sqlalchemy/orm/relationships.py", line 1675, in _process_dependent_arguments
    rel_arg._resolve_against_registry(self._clsregistry_resolvers[1])
  File "litestar-sqlalchemy-bug-report/.venv/lib/python3.11/site-packages/sqlalchemy/orm/relationships.py", line 265, in _resolve_against_registry
    self.resolved = clsregistry_resolver(
                    ^^^^^^^^^^^^^^^^^^^^^
  File "litestar-sqlalchemy-bug-report/.venv/lib/python3.11/site-packages/sqlalchemy/orm/clsregistry.py", line 532, in __call__
    x = eval(self.arg, globals(), self._dict)
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "<string>", line 1, in <module>
AttributeError: 'Table' object has no attribute 'created_at'

[!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 Litestar 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

peterschutt commented 1 year ago

Thanks for creating these issues @AgarwalPragy - very helpful. I'll do my best to take a look at them over the next day or so.

cofin commented 1 year ago

@AgarwalPragy Can you confirm that this is all of the code?

I am able to reproduce the behavior you describe. However, this example does not work outside of Litestar either. Here's an example that uses only SQLAlchemy and shows the same exception.

from __future__ import annotations

from datetime import datetime
from typing import TYPE_CHECKING, Any, ClassVar
from uuid import UUID

import anyio
from sqlalchemy import BIGINT, TIMESTAMP, Boolean, Index, MetaData, String, Uuid, func, select
from sqlalchemy.ext.asyncio import AsyncAttrs, AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import (
    DeclarativeBase,
    Mapped,
    declared_attr,
    mapped_column,
    registry,
    relationship,
)

if TYPE_CHECKING:
    from sqlalchemy.types import TypeEngine

db_url = "sqlite+aiosqlite:///:memory:"
meta = MetaData()
type_annotation_map: dict[type, type[TypeEngine[Any]] | TypeEngine[Any]] = {
    int: BIGINT,
    datetime: TIMESTAMP(timezone=True),
    str: String,
    UUID: Uuid,
    bool: Boolean,
}
orm_registry = registry(metadata=meta, type_annotation_map=type_annotation_map)
engine = create_async_engine(db_url)
async_session_factory: async_sessionmaker[AsyncSession] = async_sessionmaker(engine, expire_on_commit=False)

class ModelBase(AsyncAttrs, DeclarativeBase):
    __abstract__ = True
    __name__: ClassVar[str]
    registry = orm_registry

    id: Mapped[int] = mapped_column(primary_key=True)

    created_at: Mapped[datetime] = mapped_column(index=True, doc="Time of creation", server_default=func.now())
    updated_at: Mapped[datetime] = mapped_column(
        index=True,
        doc="Time of last modification",
        server_default=func.now(),
        onupdate=func.now(),
    )

    # noinspection PyMethodParameters
    @declared_attr.directive
    def __tablename__(cls) -> str:
        """Automatically generate table name from class name."""
        return f"{cls.__name__.lower()}s"

    def to_dict(self, exclude: set[str] | None = None) -> dict[str, Any]:
        """Convert model to dictionary.

        Returns:
            dict[str, Any]: A dict representation of the model
        """
        exclude = {"sa_orm_sentinel", "_sentinel"}.union(self._sa_instance_state.unloaded).union(exclude or [])  # type: ignore[attr-defined]
        return {field.name: getattr(self, field.name) for field in self.__table__.columns if field.name not in exclude}

class GenericItemRevision:
    target_id: Mapped[UUID] = mapped_column(index=True, nullable=False)
    data: Mapped[str]

class RevisionsMixin:
    @declared_attr.directive
    def revisions(cls) -> Mapped[list[GenericItemRevision]]:
        revision_class_name = f"{cls.__name__}Revision"
        revision_table_name = f"{cls.__name__.lower()}_revisions"

        class Revision(ModelBase, GenericItemRevision):
            __tablename__ = revision_table_name  # type: ignore
            __table_args__ = (Index(f"ix_{revision_table_name}_target_id_created_at", "target_id", "created_at"),)

        Revision.__name__ = revision_class_name

        return relationship(
            revision_class_name,
            order_by=f"desc({revision_table_name}.created_at)",
            back_populates="target",
        )

class Note(ModelBase, RevisionsMixin):
    contents: Mapped[str]

async def build_and_query() -> None:
    async with engine.begin() as conn:
        await conn.run_sync(meta.create_all)

    async with async_session_factory() as session:
        results = await session.execute(select(Note))
        print(results)

if __name__ == "__main__":
    anyio.run(build_and_query)

Also, you can simplify your dependency injection by using the built in plugin's dependency injection. You can configure it to inject a session with the transaction parameter like so:

@get("/")
async def hello(transaction: AsyncSession) -> Sequence[Note]:
    query = select(Note)
    result = await transaction.execute(query)
    return result.scalars().all()

db_url = "sqlite+aiosqlite:///:memory:"
app = Litestar(
    route_handlers=[hello],
    plugins=[
        SQLAlchemyPlugin(
            config=SQLAlchemyAsyncConfig(
                connection_string=db_url,
                session_dependency_key="transaction",
                create_all=True,
                alembic_config=AlembicAsyncConfig(target_metadata=orm_registry.metadata),
            ),
        ),
    ],
)
peterschutt commented 1 year ago

Also, you can simplify your dependency injection by using the built in plugin's dependency injection. You can configure it to inject a session with the transaction parameter like so:

The transaction dependency in OP is additional the one in the plugin - it actually depends on the plugin provided one - and opens a transaction, with handling for some dbapi errors. Its copying a pattern from the tutorial that extends the TODO app with sqlalchemy - which was written before the plugin had any auto-committing behavior.

AgarwalPragy commented 1 year ago

@cofin interesting. Since the alembic migrations were working, I assumed that my code was correct. Seems like the migrations work, but the queries don't.

My bad. Closing this issue :)

fixed my code, and it works now 🎉

AgarwalPragy commented 1 year ago
db_url = "sqlite+aiosqlite:///:memory:"
app = Litestar(
    route_handlers=[hello],
    plugins=[
        SQLAlchemyPlugin(
            config=SQLAlchemyAsyncConfig(
                connection_string=db_url,
                session_dependency_key="transaction",
                create_all=True,
                alembic_config=AlembicAsyncConfig(target_metadata=orm_registry.metadata),
            ),
        ),
    ],
)

With litestar==2.2.1, I get TypeError: SQLAlchemyAsyncConfig.__init__() got an unexpected keyword argument 'create_all'

peterschutt commented 1 year ago

make sure you have latest advanced-alchemy package - this parameter was only just added.

AgarwalPragy commented 1 year ago
db_url = "sqlite+aiosqlite:///:memory:"
app = Litestar(
    route_handlers=[hello],
    plugins=[
        SQLAlchemyPlugin(
            config=SQLAlchemyAsyncConfig(
                connection_string=db_url,
                session_dependency_key="transaction",
                create_all=True,
                alembic_config=AlembicAsyncConfig(target_metadata=orm_registry.metadata),
            ),
        ),
    ],
)

@cofin this might be due to my lack of understanding, but adding session_dependency_key="transaction" prevents any data from being saved to the db. With this line, my POST endpoints return the data, but nothing is saved in db. Commenting this line make them work as expected!

cofin commented 1 year ago

@AgarwalPragy You are correct. By default, the plugin session handler does not automatically commit on a successful response. You can easily change it by using the following before_send handler:

from advanced_alchemy.extensions.litestar.plugins.init.config.asyncio import autocommit_before_send_handler

db_url = "sqlite+aiosqlite:///:memory:"
app = Litestar(
    route_handlers=[hello],
    plugins=[
        SQLAlchemyPlugin(
            config=SQLAlchemyAsyncConfig(
                connection_string=db_url,
                session_dependency_key="transaction",
                create_all=True,
                alembic_config=AlembicAsyncConfig(target_metadata=orm_registry.metadata),
                before_send_handler=autocommit_before_send_handler,
            ),
        ),
    ],
)
peterschutt commented 1 year ago

@AgarwalPragy You are correct. By default, the plugin session handler does not automatically commit on a successful response. You can easily change it by using the following before_send handler:

from advanced_alchemy.extensions.litestar.plugins.init.config.asyncio import autocommit_before_send_handler

db_url = "sqlite+aiosqlite:///:memory:"
app = Litestar(
    route_handlers=[hello],
    plugins=[
        SQLAlchemyPlugin(
            config=SQLAlchemyAsyncConfig(
                connection_string=db_url,
                session_dependency_key="transaction",
                create_all=True,
                alembic_config=AlembicAsyncConfig(target_metadata=orm_registry.metadata),
                before_send_handler=autocommit_before_send_handler,
            ),
        ),
    ],
)

I'd say this is a documentation bug on our side now.

We should update https://docs.litestar.dev/latest/tutorials/sqlalchemy/3-init-plugin.html to do the same as this (it was written before the autocommit handler was a part of the plugin, IIRC).