DEV Community

Eric Berry
Eric Berry

Posted on

Implementing the Active Record Pattern in Python with SQLModel

While working with SQLModel in Python, I found myself missing the elegant database interactions of Rails. With a lot of assistance from Claude, I discovered this pattern for bringing Active Record-style elegance to Python while maintaining its strong typing benefits.

When transitioning from Ruby on Rails to Python, many developers miss the elegance of Active Record. While Python's SQLAlchemy (and by extension, SQLModel) takes a different approach by default, we can implement a similar pattern that brings the convenience of Rails-style models to our Python applications while maintaining type safety and following Python best practices.

The Active Record Pattern

The Active Record pattern, popularized by Ruby on Rails, treats database records as objects with methods for database operations. Instead of using separate repository classes or data access objects (DAOs), the model itself knows how to interact with the database.

For example, in Rails you might write:

# Find a record
user = User.find(123)

# Update it
user.name = "New Name"
user.save

# Create a new record
post = Post.create(title: "Hello World")
Enter fullscreen mode Exit fullscreen mode

Implementing in Python with SQLModel

While Python's SQLModel doesn't provide this pattern out of the box, we can implement it with a base class that provides these familiar operations. Here's how:

1. The Base CRUD Model

First, we create a base class that implements common CRUD operations:

from typing import TypeVar, List, Optional, Tuple
from datetime import datetime
import uuid
from sqlmodel import SQLModel, Session, select
from sqlalchemy import func

T = TypeVar("T", bound="CRUDModel")

class CRUDModel(SQLModel):
    id: str = Field(
        default_factory=lambda: str(uuid.uuid4()),
        primary_key=True
    )
    created_at: datetime = Field(default_factory=datetime.utcnow)
    updated_at: datetime = Field(default_factory=datetime.utcnow)

    @classmethod
    def all(cls: type[T], session: Session) -> List[T]:
        statement = select(cls)
        return session.exec(statement).all()

    @classmethod
    def find(cls: type[T], session: Session, id: str) -> Optional[T]:
        statement = select(cls).where(cls.id == id)
        return session.exec(statement).first()

    @classmethod
    def create(cls: type[T], session: Session, **kwargs) -> T:
        db_obj = cls(**kwargs)
        session.add(db_obj)
        session.commit()
        session.refresh(db_obj)
        return db_obj

    def update(self: T, session: Session, **kwargs) -> T:
        kwargs['updated_at'] = datetime.utcnow()
        for key, value in kwargs.items():
            setattr(self, key, value)
        session.add(self)
        session.commit()
        session.refresh(self)
        return self

    def delete(self: T, session: Session) -> None:
        session.delete(self)
        session.commit()

    @classmethod
    def paginate(
        cls: type[T],
        session: Session,
        page: int = 1,
        per_page: int = 20
    ) -> Tuple[List[T], int]:
        statement = select(cls)
        total = session.exec(select(func.count()).select_from(statement)).one()

        offset = (page - 1) * per_page
        results = session.exec(
            statement.offset(offset).limit(per_page)
        ).all()

        return results, total
Enter fullscreen mode Exit fullscreen mode

2. Using the Pattern in Your Models

With our base class defined, we can create models that inherit from it:

class Article(CRUDModel, table=True):
    title: str = Field(..., description="Article title")
    content: str = Field(..., description="Article content")
    status: str = Field(default="draft")

    # Relationships
    comments: List["Comment"] = Relationship(
        back_populates="article",
        sa_relationship_kwargs={"cascade": "all, delete-orphan"}
    )
Enter fullscreen mode Exit fullscreen mode

3. Using the Models

Now we can use our models with a familiar Rails-like syntax, while maintaining Python's explicit session management:

from db.session import get_session

# List all articles
with get_session() as session:
    articles = Article.all(session)

# Find a specific article
with get_session() as session:
    article = Article.find(session, "some-uuid")
    if article:
        print(f"Found: {article.title}")

# Create a new article
with get_session() as session:
    article = Article.create(
        session,
        title="My New Article",
        content="Some content here"
    )

# Update an article
with get_session() as session:
    article = Article.find(session, "some-uuid")
    if article:
        updated = article.update(
            session,
            title="Updated Title",
            content="New content"
        )

# Delete an article
with get_session() as session:
    article = Article.find(session, "some-uuid")
    if article:
        article.delete(session)

# Pagination
with get_session() as session:
    articles, total = Article.paginate(session, page=2, per_page=10)
Enter fullscreen mode Exit fullscreen mode

Key Differences from Rails

While this pattern brings Rails-like convenience to Python, there are some important differences to note:

  1. Explicit Session Management: Python requires explicit session management, which promotes better understanding of database transactions.
# Python with SQLModel
with get_session() as session:
    article = Article.create(session, title="Hello")

# vs Rails
article = Article.create(title: "Hello")
Enter fullscreen mode Exit fullscreen mode
  1. Type Safety: Python's type hints provide better IDE support and catch errors earlier.
class Article(CRUDModel, table=True):
    title: str  # Type safety!
    views: int = Field(default=0)
Enter fullscreen mode Exit fullscreen mode
  1. Class Methods: Python uses explicit @classmethod decorators for operations that don't require an instance.

  2. Error Handling: Python encourages explicit exception handling:

with get_session() as session:
    try:
        article = Article.find(session, "non-existent")
        if article is None:
            raise HTTPException(status_code=404, detail="Article not found")
    except Exception as e:
        # Handle other database errors
        raise HTTPException(status_code=500, detail=str(e))
Enter fullscreen mode Exit fullscreen mode

Best Practices

When using this pattern in Python, keep these best practices in mind:

  1. Always Use Context Managers:
   # Good
   with get_session() as session:
       article = Article.create(session, title="Hello")

   # Not Good
   session = get_session()
   article = Article.create(session, title="Hello")
   session.close()
Enter fullscreen mode Exit fullscreen mode
  1. Type Safety:
   # Use proper type hints
   def get_article(id: str) -> Optional[Article]:
       with get_session() as session:
           return Article.find(session, id)
Enter fullscreen mode Exit fullscreen mode
  1. Validation:
   class Article(CRUDModel, table=True):
       title: str = Field(..., min_length=1, max_length=100)
       status: str = Field(
           default="draft",
           validate_default=True,
           validator=lambda x: x in ["draft", "published"]
       )
Enter fullscreen mode Exit fullscreen mode
  1. Relationship Management:
   class Article(CRUDModel, table=True):
       # Use cascade deletes appropriately
       comments: List["Comment"] = Relationship(
           back_populates="article",
           sa_relationship_kwargs={"cascade": "all, delete-orphan"}
       )
Enter fullscreen mode Exit fullscreen mode

Conclusion

The Active Record pattern can be effectively implemented in Python while maintaining type safety and following Python best practices. While it requires more explicit session management than Rails, it provides similar convenience while giving developers more control over database operations.

This pattern is particularly useful for:

  • Teams transitioning from Rails to Python
  • Projects that prefer model-centric database operations
  • Applications where type safety and explicit session management are important

Remember that this is just one approach to database operations in Python. SQLModel and SQLAlchemy support other patterns like repositories or data access objects, which might be more appropriate for certain use cases.

Resources

Top comments (0)

Billboard image

The Next Generation Developer Platform

Coherence is the first Platform-as-a-Service you can control. Unlike "black-box" platforms that are opinionated about the infra you can deploy, Coherence is powered by CNC, the open-source IaC framework, which offers limitless customization.

Learn more

👋 Kindness is contagious

Please leave a ❤️ or a friendly comment on this post if you found it helpful!

Okay