Repository: litestar-org/litestar

Issue:
Bug: SQLAlchemy Plugin won't accept `async_sessionmaker` for `session_maker_instance`
```
app_1    | Traceback (most recent call last):
app_1    |   File "/usr/local/bin/alembic", line 8, in <module>
app_1    |     sys.exit(main())
app_1    |   File "/usr/local/lib/python3.10/site-packages/alembic/config.py", line 590, in main
app_1    |     CommandLine(prog=prog).main(argv=argv)
app_1    |   File "/usr/local/lib/python3.10/site-packages/alembic/config.py", line 584, in main
app_1    |     self.run_cmd(cfg, options)
app_1    |   File "/usr/local/lib/python3.10/site-packages/alembic/config.py", line 561, in run_cmd
app_1    |     fn(
app_1    |   File "/usr/local/lib/python3.10/site-packages/alembic/command.py", line 322, in upgrade
app_1    |     script.run_env()
app_1    |   File "/usr/local/lib/python3.10/site-packages/alembic/script/base.py", line 569, in run_env
app_1    |     util.load_python_file(self.dir, "env.py")
app_1    |   File "/usr/local/lib/python3.10/site-packages/alembic/util/pyfiles.py", line 94, in load_python_file
app_1    |     module = load_module_py(module_id, path)
app_1    |   File "/usr/local/lib/python3.10/site-packages/alembic/util/pyfiles.py", line 110, in load_module_py
app_1    |     spec.loader.exec_module(module)  # type: ignore
app_1    |   File "<frozen importlib._bootstrap_external>", line 883, in exec_module
app_1    |   File "<frozen importlib._bootstrap>", line 241, in _call_with_frames_removed
app_1    |   File "/app/alembic/env.py", line 9, in <module>
app_1    |     from app.lib import orm, settings
app_1    |   File "/app/./app/lib/__init__.py", line 3, in <module>
app_1    |     from . import (
app_1    |   File "/app/./app/lib/sqlalchemy_plugin.py", line 111, in <module>
app_1    |     config = SQLAlchemyConfig(
app_1    |   File "pydantic/main.py", line 342, in pydantic.main.BaseModel.__init__
app_1    | pydantic.error_wrappers.ValidationError: 1 validation error for SQLAlchemyConfig
app_1    | session_maker_instance
app_1    |   instance of sessionmaker expected (type=type_error.arbitrary_type; expected_arbitrary_type=sessionmaker)
```
`async_sessionmaker` doesn't exist in 1.x and unfortunately isn't a subclass of `sessionmaker`.

I'm think of making these protocols instead - for `session_maker_class` we care that we receive a type that has the `sessionmaker.__init__()` interface, and creates a callable that returns a session. For `session_maker_instance` we only care that we receive a callable that returns either `Session` or `AsyncSession` - so pretty sure I can express all of that with protocols, without needing to strongly type to `sessionmaker`.


Discussion:

Resolving patch:
diff --git a/starlite/plugins/sql_alchemy/config.py b/starlite/plugins/sql_alchemy/config.py
--- a/starlite/plugins/sql_alchemy/config.py
+++ b/starlite/plugins/sql_alchemy/config.py
@@ -20,6 +20,8 @@
 from starlite.types import BeforeMessageSendHookHandler
 from starlite.utils import AsyncCallable, default_serializer
 
+from .types import SessionMakerInstanceProtocol, SessionMakerTypeProtocol
+
 try:
     from sqlalchemy import create_engine
     from sqlalchemy.engine import Engine
@@ -200,7 +202,7 @@ class Config(BaseConfig):
     Configuration options for the 'sessionmaker'. The configuration options are documented in the
         SQLAlchemy documentation.
     """
-    session_maker_class: Type[sessionmaker] = sessionmaker
+    session_maker_class: Type[SessionMakerTypeProtocol] = sessionmaker
     """
     Sessionmaker class to use.
     """
@@ -208,7 +210,7 @@ class Config(BaseConfig):
     """
     Key under which to store the SQLAlchemy 'sessionmaker' in the application [State][starlite.datastructures.State] instance.
     """
-    session_maker_instance: Optional[sessionmaker] = None
+    session_maker_instance: Optional[SessionMakerInstanceProtocol] = None
     """
     Optional sessionmaker to use. If set, the plugin will use the provided instance rather than instantiate a sessionmaker.
     """
diff --git a/starlite/plugins/sql_alchemy/plugin.py b/starlite/plugins/sql_alchemy/plugin.py
--- a/starlite/plugins/sql_alchemy/plugin.py
+++ b/starlite/plugins/sql_alchemy/plugin.py
@@ -25,6 +25,8 @@
 )
 from starlite.plugins.base import PluginProtocol
 
+from .types import SQLAlchemyBinaryType
+
 try:
     from sqlalchemy import inspect
     from sqlalchemy import types as sqlalchemy_type
@@ -95,7 +97,7 @@ def is_plugin_supported_type(value: Any) -> "TypeGuard[DeclarativeMeta]":
         return isinstance(inspected, (Mapper, InstanceState))
 
     @staticmethod
-    def handle_string_type(column_type: Union[sqlalchemy_type.String, sqlalchemy_type._Binary]) -> "Type":
+    def handle_string_type(column_type: Union[sqlalchemy_type.String, SQLAlchemyBinaryType]) -> "Type":
         """Handles the SQLAlchemy String types, including Blob and Binary
         types.
 
@@ -138,7 +140,7 @@ def handle_list_type(self, column_type: sqlalchemy_type.ARRAY) -> Any:
             dimensions -= 1
         return list_type
 
-    def handle_tuple_type(self, column_type: sqlalchemy_type.TupleType) -> Any:
+    def handle_tuple_type(self, column_type: sqlalchemy_type.TupleType) -> Any:  # type:ignore[name-defined]
         """Handles the SQLAlchemy Tuple type.
 
         Args:
@@ -160,10 +162,10 @@ def handle_enum(column_type: Union[sqlalchemy_type.Enum, mysql.ENUM, postgresql.
         Returns:
             An appropriate enum type
         """
-        return column_type.enum_class
+        return column_type.enum_class  # type:ignore[union-attr]
 
     @property
-    def providers_map(self) -> Dict["Type[TypeEngine]", Callable[[Union[TypeEngine, "Type[TypeEngine]"]], Any]]:
+    def providers_map(self) -> Dict[Type[TypeEngine], Callable[[Union[TypeEngine, Type[TypeEngine]]], Any]]:
         """A map of SQLAlchemy column types to provider functions.
 
         This method is separated to allow for easy overriding in
@@ -209,7 +211,7 @@ def providers_map(self) -> Dict["Type[TypeEngine]", Callable[[Union[TypeEngine,
             sqlalchemy_type.TIMESTAMP: lambda x: datetime,
             sqlalchemy_type.Text: self.handle_string_type,
             sqlalchemy_type.Time: lambda x: time,
-            sqlalchemy_type.TupleType: self.handle_tuple_type,
+            sqlalchemy_type.TupleType: self.handle_tuple_type,  # type:ignore[attr-defined]
             sqlalchemy_type.Unicode: self.handle_string_type,
             sqlalchemy_type.UnicodeText: self.handle_string_type,
             sqlalchemy_type.VARBINARY: self.handle_string_type,
@@ -325,7 +327,7 @@ def get_pydantic_type(self, column_type: Any) -> Any:
         return type(column_type)
 
     @staticmethod
-    def parse_model(model_class: DeclarativeMeta) -> Mapper:
+    def parse_model(model_class: Type[DeclarativeMeta]) -> Mapper:
         """Validates that the passed in model_class is an SQLAlchemy
         declarative model, and returns a Mapper of it.
 
diff --git a/starlite/plugins/sql_alchemy/types.py b/starlite/plugins/sql_alchemy/types.py
new file mode 100644
--- /dev/null
+++ b/starlite/plugins/sql_alchemy/types.py
@@ -0,0 +1,35 @@
+from typing import TYPE_CHECKING, Any, Dict, Optional, Type, Union
+
+from typing_extensions import Protocol, runtime_checkable
+
+if TYPE_CHECKING:
+    from sqlalchemy.engine import Connection, Engine
+    from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
+    from sqlalchemy.orm import Session
+    from sqlalchemy.types import BINARY, VARBINARY, LargeBinary
+
+
+SQLAlchemyBinaryType = Union["BINARY", "VARBINARY", "LargeBinary"]
+
+
+@runtime_checkable
+class SessionMakerTypeProtocol(Protocol):
+    def __init__(
+        self,
+        bind: "Optional[Union[AsyncEngine, Engine, Connection]]",
+        class_: "Union[Type[AsyncSession], Type[Session]]",
+        autoflush: bool,
+        expire_on_commit: bool,
+        info: Dict[Any, Any],
+        **kwargs: Any,
+    ) -> None:
+        ...
+
+    def __call__(self) -> "Union[Session, AsyncSession]":
+        ...
+
+
+@runtime_checkable
+class SessionMakerInstanceProtocol(Protocol):
+    def __call__(self) -> "Union[Session, AsyncSession]":
+        ...


Test patch:
diff --git a/tests/dto_factory/test_dto_factory_model_conversion.py b/tests/dto_factory/test_dto_factory_model_conversion.py
--- a/tests/dto_factory/test_dto_factory_model_conversion.py
+++ b/tests/dto_factory/test_dto_factory_model_conversion.py
@@ -64,7 +64,7 @@ def test_conversion_from_model_instance(
             pets=None,
         )
     else:
-        model_instance = cast("Type[Pet]", model)(  # type: ignore[call-arg]
+        model_instance = cast("Type[Pet]", model)(
             id=1,
             species=Species.MONKEY,
             name="Mike",
diff --git a/tests/plugins/sql_alchemy_plugin/__init__.py b/tests/plugins/sql_alchemy_plugin/__init__.py
--- a/tests/plugins/sql_alchemy_plugin/__init__.py
+++ b/tests/plugins/sql_alchemy_plugin/__init__.py
@@ -1,3 +1,5 @@
+from typing import List
+
 from sqlalchemy import Column, Enum, Float, ForeignKey, Integer, String, Table
 from sqlalchemy.orm import as_declarative, declared_attr, relationship
 
@@ -11,18 +13,18 @@ class SQLAlchemyBase:
     # Generate the table name from the class name
     @declared_attr
     def __tablename__(cls) -> str:
-        return cls.__name__.lower()  # type:ignore[no-any-return,attr-defined]
+        return cls.__name__.lower()
 
 
 association_table = Table(
     "association",
-    SQLAlchemyBase.metadata,  # type:ignore[attr-defined]
+    SQLAlchemyBase.metadata,
     Column("pet_id", ForeignKey("pet.id")),
     Column("user_id", ForeignKey("user.id")),
 )
 friendship_table = Table(
     "friendships",
-    SQLAlchemyBase.metadata,  # type:ignore[attr-defined]
+    SQLAlchemyBase.metadata,
     Column("friend_a_id", Integer, ForeignKey("user.id"), primary_key=True),
     Column("friend_b_id", Integer, ForeignKey("user.id"), primary_key=True),
 )
@@ -34,7 +36,7 @@ class Pet(SQLAlchemyBase):
     name = Column(String)
     age = Column(Float)
     owner_id = Column(Integer, ForeignKey("user.id"))
-    owner = relationship("User", back_populates="pets", uselist=False)
+    owner: "User" = relationship("User", back_populates="pets", uselist=False)
 
 
 class Company(SQLAlchemyBase):
@@ -46,8 +48,8 @@ class Company(SQLAlchemyBase):
 class User(SQLAlchemyBase):
     id = Column(Integer, primary_key=True)
     name = Column(String, default="moishe")
-    pets = relationship("Pet", back_populates="owner", uselist=True)
-    friends = relationship(
+    pets: List[Pet] = relationship("Pet", back_populates="owner", uselist=True)
+    friends: List["User"] = relationship(
         "User",
         secondary=friendship_table,
         primaryjoin=id == friendship_table.c.friend_a_id,
@@ -55,4 +57,4 @@ class User(SQLAlchemyBase):
         uselist=True,
     )
     company_id = Column(Integer, ForeignKey("company.id"))
-    company = relationship("Company")
+    company: Company = relationship("Company")
diff --git a/tests/plugins/sql_alchemy_plugin/test_sql_alchemy_config.py b/tests/plugins/sql_alchemy_plugin/test_sql_alchemy_config.py
--- a/tests/plugins/sql_alchemy_plugin/test_sql_alchemy_config.py
+++ b/tests/plugins/sql_alchemy_plugin/test_sql_alchemy_config.py
@@ -4,7 +4,7 @@
 from pydantic import ValidationError
 from sqlalchemy import create_engine
 from sqlalchemy.ext.asyncio import AsyncSession
-from sqlalchemy.orm import Session
+from sqlalchemy.orm import Session, sessionmaker
 from starlette.status import HTTP_200_OK
 
 from starlite import LoggingConfig, Starlite, get
@@ -116,3 +116,18 @@ def test_config_connection_string_or_engine_instance_validation() -> None:
     # these should be OK
     SQLAlchemyConfig(engine_instance=engine)
     SQLAlchemyConfig(connection_string=connection_string)
+
+
+def test_config_session_maker_class_protocol() -> None:
+    """Tests that pydantic allows the type, but also relies on mypy checking
+    that `sessionmaker` conforms to the protocol."""
+    SQLAlchemyConfig(connection_string="sqlite:///", session_maker_class=sessionmaker)
+
+
+def test_config_session_maker_instance_protocol() -> None:
+    """Tests that pydantic allows the type, but also relies on mypy checking
+    that `sessionmaker` conforms to the protocol."""
+    SQLAlchemyConfig(connection_string="sqlite:///", session_maker_instance=sessionmaker())
+    # instance can be any callable that returns a session type instance
+    SQLAlchemyConfig(connection_string="sqlite:///", session_maker_instance=Session)
+    SQLAlchemyConfig(connection_string="sqlite:///", session_maker_instance=AsyncSession)
diff --git a/tests/plugins/sql_alchemy_plugin/test_sql_alchemy_plugin_integration.py b/tests/plugins/sql_alchemy_plugin/test_sql_alchemy_plugin_integration.py
--- a/tests/plugins/sql_alchemy_plugin/test_sql_alchemy_plugin_integration.py
+++ b/tests/plugins/sql_alchemy_plugin/test_sql_alchemy_plugin_integration.py
@@ -12,7 +12,7 @@
 from tests.plugins.sql_alchemy_plugin import Company
 
 companies = [
-    Company(id=i, name=create_random_string(min_length=5, max_length=20), worth=create_random_float(minimum=1))  # type: ignore[call-arg]
+    Company(id=i, name=create_random_string(min_length=5, max_length=20), worth=create_random_float(minimum=1))
     for i in range(3)
 ]
 
diff --git a/tests/plugins/sql_alchemy_plugin/test_sql_alchemy_relationships.py b/tests/plugins/sql_alchemy_plugin/test_sql_alchemy_relationships.py
--- a/tests/plugins/sql_alchemy_plugin/test_sql_alchemy_relationships.py
+++ b/tests/plugins/sql_alchemy_plugin/test_sql_alchemy_relationships.py
@@ -8,7 +8,7 @@
 
 
 def test_relationship() -> None:
-    result = SQLAlchemyPlugin().to_pydantic_model_class(model_class=User)
+    result = SQLAlchemyPlugin().to_pydantic_model_class(model_class=User)  # type:ignore[arg-type]
     assert issubclass(result, BaseModel)
     result.update_forward_refs()
     fields = result.__fields__
diff --git a/tests/plugins/sql_alchemy_plugin/test_sql_alchemy_table_types.py b/tests/plugins/sql_alchemy_plugin/test_sql_alchemy_table_types.py
--- a/tests/plugins/sql_alchemy_plugin/test_sql_alchemy_table_types.py
+++ b/tests/plugins/sql_alchemy_plugin/test_sql_alchemy_table_types.py
@@ -221,7 +221,7 @@ class MyClass(BaseModel):
 
 
 def test_provider_validation() -> None:
-    class MyStrColumn(sqlalchemy.String):  # type:ignore[misc]
+    class MyStrColumn(sqlalchemy.String):
         pass
 
     class ModelWithCustomColumn(SQLAlchemyBase):
diff --git a/tests/plugins/sql_alchemy_plugin/test_sqlalchemy_plugin.py b/tests/plugins/sql_alchemy_plugin/test_sqlalchemy_plugin.py
--- a/tests/plugins/sql_alchemy_plugin/test_sqlalchemy_plugin.py
+++ b/tests/plugins/sql_alchemy_plugin/test_sqlalchemy_plugin.py
@@ -20,7 +20,7 @@ class FromAsDeclarative(AsDeclBase):
     id = Column(Integer, primary_key=True)
 
 
-class FromDeclBase(DeclBase):  # type:ignore[misc,valid-type]
+class FromDeclBase(DeclBase):
     __tablename__ = "whatever"
     id = Column(Integer, primary_key=True)