Skip to content

Models & Concerns

Fastpy models extend SQLModel with powerful concerns (mixins) for enhanced functionality.

Base Model

All models inherit from BaseModel which provides:

python
from app.models.base import BaseModel

class Post(BaseModel, table=True):
    __tablename__ = "posts"

    title: str = Field(max_length=200)
    body: str = Field(sa_column=Column(Text))

Standard Fields

Every model automatically includes:

FieldTypeDescription
idintAuto-incrementing primary key
created_atdatetimeTimestamp on creation
updated_atdatetimeTimestamp on update
deleted_atdatetime?Soft delete timestamp

Built-in Methods

python
# Soft delete
post.soft_delete()
await session.commit()

# Restore
post.restore()
await session.commit()

# Check if deleted
if post.is_deleted:
    print("Post is soft deleted")

# Update timestamps
post.touch()

Active Record Methods

Models support Active Record-style operations:

python
# Create
user = await User.create(name="John", email="john@example.com")

# Find
user = await User.find(1)                          # Returns None if not found
user = await User.find_or_fail(1)                  # Raises NotFoundException

# Query
users = await User.where(active=True)              # List of matching records
user = await User.first_where(email="john@example.com")
user = await User.first_or_fail(email="john@example.com")

# Update
user.name = "Jane"
await user.save()

await user.update(name="Jane", email="jane@example.com")

# Delete
await user.delete()              # Soft delete
await user.delete(force=True)    # Hard delete

Model Concerns

Mix in powerful functionality to your models using concerns:

python
from app.models.base import BaseModel
from app.models.concerns import (
    HasCasts, HasAttributes, HasEvents, HasScopes, GuardsAttributes
)

class Post(BaseModel, HasCasts, HasAttributes, HasEvents, HasScopes, GuardsAttributes, table=True):
    __tablename__ = "posts"

    title: str
    body: str
    settings: Optional[str] = None
    is_published: bool = False
    views: int = 0

HasCasts

Auto-convert database values to Python types:

python
class Post(BaseModel, HasCasts, table=True):
    _casts = {
        'settings': 'json',        # JSON string <-> dict
        'is_active': 'boolean',    # 1/0 <-> True/False
        'metadata': 'dict',        # Ensure dict type
        'tags': 'list',            # Ensure list type
        'price': 'decimal:2',      # Decimal with 2 places
        'published_at': 'datetime',
    }

Usage

python
post.settings = {'featured': True}  # Stored as JSON string
print(post.settings)                 # Returns dict: {'featured': True}

Available Cast Types

Cast TypeDescription
booleanConvert to True/False
integerConvert to int
floatConvert to float
stringConvert to str
jsonJSON encode/decode
dictEnsure dict type
listEnsure list type
dateConvert to date object
datetimeConvert to datetime object
decimal:NDecimal with N places

HasAttributes

Computed properties and value transformers:

python
from app.models.concerns import accessor, mutator

class User(BaseModel, HasAttributes, table=True):
    first_name: str
    last_name: str
    password: str

    # Virtual attributes to include in serialization
    _appends = ['full_name']
    _hidden = ['password']

    @accessor
    def full_name(self) -> str:
        """Computed property."""
        return f"{self.first_name} {self.last_name}"

    @mutator('password')
    def hash_password(self, value: str) -> str:
        """Transform value before storing."""
        from app.utils.auth import get_password_hash
        return get_password_hash(value)

Usage

python
user.full_name          # "John Doe" (computed)
user.password = "secret" # Automatically hashed
user.to_dict()          # Includes full_name, excludes password

Configuration

PropertyDescription
_appendsVirtual attributes to include in serialization
_hiddenAttributes to exclude from serialization
_visibleOnly include these attributes in serialization

HasEvents

Hook into model lifecycle:

python
from app.models.concerns import HasEvents, ModelObserver

class UserObserver(ModelObserver):
    def creating(self, user):
        user.uuid = str(uuid4())

    def created(self, user):
        send_welcome_email(user)

    def updating(self, user):
        pass

    def updated(self, user):
        pass

    def deleting(self, user):
        # Return False to cancel deletion
        if user.is_admin:
            return False
        return True

    def deleted(self, user):
        pass

class User(BaseModel, HasEvents, table=True):
    @classmethod
    def booted(cls):
        cls.observe(UserObserver())

        # Or inline handlers:
        cls.creating(lambda u: setattr(u, 'uuid', str(uuid4())))

Available Events

EventDescription
creatingBefore a new model is saved
createdAfter a new model is saved
updatingBefore an existing model is updated
updatedAfter an existing model is updated
savingBefore any save (create or update)
savedAfter any save (create or update)
deletingBefore a model is deleted
deletedAfter a model is deleted
restoringBefore a soft-deleted model is restored
restoredAfter a soft-deleted model is restored

HasScopes

Reusable query constraints:

python
class Post(BaseModel, HasScopes, table=True):
    @classmethod
    def scope_published(cls, query):
        return query.where(cls.is_published == True)

    @classmethod
    def scope_popular(cls, query, min_views: int = 1000):
        return query.where(cls.views >= min_views)

    @classmethod
    def scope_by_author(cls, query, author_id: int):
        return query.where(cls.author_id == author_id)

Usage

python
# Fluent query building
posts = await Post.query().published().popular(5000).latest().get()
posts = await Post.query().by_author(1).paginate(page=2, per_page=20)

# With soft deletes
posts = await Post.query().with_trashed().get()   # Include deleted
posts = await Post.query().only_trashed().get()   # Only deleted

QueryBuilder Methods

MethodDescription
where(**kwargs)Filter by conditions
where_in(field, values)Filter where field in values
where_null(field)Filter where field is null
where_not_null(field)Filter where field is not null
order_by(field, direction)Order results
latest(field='created_at')Order by newest first
oldest(field='created_at')Order by oldest first
limit(n)Limit results
offset(n)Skip results
get()Execute and get all results
first()Get first result
count()Count results
exists()Check if any results exist
paginate(page, per_page)Paginate results
with_trashed()Include soft-deleted
only_trashed()Only soft-deleted

GuardsAttributes

Protect against mass assignment vulnerabilities:

python
class User(BaseModel, GuardsAttributes, table=True):
    # Whitelist: only these can be mass-assigned
    _fillable = ['name', 'email', 'password']

    # OR blacklist: everything except these
    _guarded = ['is_admin', 'role']

Usage

python
# Safe mass assignment
user = await User.create(**request.validated_data)  # Only fillable fields
user.fill(name="John", is_admin=True)               # is_admin ignored

# Bypass protection when needed
user.force_fill(is_admin=True)

# Temporarily disable protection
from app.models.concerns import Unguarded
with Unguarded(User):
    await User.create(is_admin=True)

Configuration

PropertyDescription
_fillableWhitelist of mass-assignable fields
_guardedBlacklist of protected fields

WARNING

Use either _fillable OR _guarded, not both. _fillable is recommended for explicit control.

Combining Concerns

Mix multiple concerns for powerful models:

python
from app.models.base import BaseModel
from app.models.concerns import (
    HasCasts, HasAttributes, HasEvents, HasScopes, GuardsAttributes
)

class Post(
    BaseModel,
    HasCasts,
    HasAttributes,
    HasEvents,
    HasScopes,
    GuardsAttributes,
    table=True
):
    __tablename__ = "posts"

    title: str
    slug: str
    body: str
    settings: Optional[str] = None
    is_published: bool = False
    published_at: Optional[datetime] = None
    author_id: int
    views: int = 0

    # Casts
    _casts = {
        'settings': 'json',
        'published_at': 'datetime',
    }

    # Attributes
    _appends = ['excerpt', 'reading_time']
    _hidden = []

    # Guards
    _fillable = ['title', 'body', 'settings']

    @accessor
    def excerpt(self) -> str:
        return self.body[:200] + '...' if len(self.body) > 200 else self.body

    @accessor
    def reading_time(self) -> int:
        words = len(self.body.split())
        return max(1, words // 200)

    @mutator('title')
    def set_title(self, value: str) -> str:
        # Auto-generate slug when title is set
        self.slug = slugify(value)
        return value

    @classmethod
    def scope_published(cls, query):
        return query.where(cls.is_published == True)

    @classmethod
    def scope_by_author(cls, query, author_id: int):
        return query.where(cls.author_id == author_id)

    @classmethod
    def booted(cls):
        cls.creating(lambda p: setattr(p, 'views', 0))

Usage

python
# Create with mass assignment protection
post = await Post.create(
    title="My Post",
    body="Content here...",
    settings={'featured': True},
    is_admin=True  # Ignored - not fillable
)

# Query with scopes
posts = await Post.query().published().by_author(1).latest().get()

# Access computed properties
print(post.excerpt)       # "Content here..."
print(post.reading_time)  # 1

# Settings auto-cast to dict
print(post.settings['featured'])  # True

Released under the MIT License.