pythongenericspython-typingsqlmodel

Using generics to write an abstract ObjectRepository class


Edit: As noted in the comments by @PanagiotisKanavos, this method to interact with databases is redundant. Since the main question is about usage of Generics, I'll leave the question open.

Original Text:

I'm trying to write a generic class that acts as the Database Repository for a number of different SQLModel models.

I have bound the _ModelType TypeVar to SQLModel hoping that it would remove all type warnings, but I still get warnings in when I return SQLModel types in methods that expect _ModelType. I also tried constraining the _ModelType to a SQLModel too, but it didn't make any difference.

Here is the abstract repository definition.

from sqlmodel import SQLModel
import typing
from sqlalchemy.engine.result import ScalarResult

    
_ModelType = typing.TypeVar(
    "_ModelType",
    bound=SQLModel,
)


class BaseRepository(typing.Generic[_ModelType]):

    def __init__(self, session: Session, model_class: typing.Type[SQLModel]):
        self.session = session
        self.__model_class = model_class

    # Static hinting works as expected
    def save(self, obj: _ModelType) -> _ModelType:
        """
        Generic method to save an object to the database.
        """
        self.session.add(obj)
        return obj
    
    def get_one(self, obj_id: typing.Any) -> typing.Optional[_ModelType]:
        obj = self.session.get(self.__model_class, obj_id) #SQLModel | None
        return obj 
        # Warning: Type "SQLModel" is not assignable to type "_ModelType@BaseRepository"

    def query(self, 
              *conditions: typing.Union[_ColumnExpressionArgument[bool], bool]
              ) -> ScalarResult[_ModelType]:

        stmt = select(self.__model_class)
        for c in conditions:
            stmt = stmt.where(c)

        return self.session.exec(stmt).all() 
            #Similar warning along with: Type parameter "_T_co@Sequence" is covariant,
            #but "SQLModel" is not a subtype of "_ModelType@BaseRepository"

and here is a basic example of usage (The actual implementation wraps the session creation, management, all repositories creation, and auto-commit on exit in a UnitOfWork context manager).

from utils.db_new import engine
from order_management.repository_new import BaseRepository
from order_management.models import Order, Customer
from sqlmodel import Session
import logging

logging.basicConfig(level=logging.INFO)

order_repo = BaseRepository[Order](Session(engine), Order)

all_orders = order_repo.query(
    Order.pickup_type == "pickup",
    Customer.name == "Diaa Malek",
)
[print(o.id) for o in all_orders]

order = order_repo.get_one(1088)
if order:
    print(order.items)

Functionality-wise, it works exactly as intended. Each return type is annotated correctly. I could also just type: ignore and move on with my life.

But I'm still not sure I understand those warnings or how to avoid them. Am I misusing Generics in some way, or is this an anti-pattern or overcomplication that should not even exist?

Also, is it possible to use _ModelType directly instead of passing the model_type in the Repository constructor? I realize it doesn't make any difference, but I wonder.

References:

Difference between TypeVar('T', A, B) and TypeVar('T', bound=Union[A, B])

Repository Design Pattern


Solution

  • I think you would have less typing issues if model_class was typed as typing.Type[_ModelType]. (In the absence of a more self-contained example, it is difficult to see what the precise impact would be.)