SQLModel with developer ergonomics. Make SQLModel act like ActiveRecord.
Project description
ActiveModel: ORM Wrapper for SQLModel
No, this isn't really ActiveModel. It's just a wrapper around SQLModel that provides a ActiveRecord-like interface.
SQLModel is not an ORM. It's a SQL query builder and a schema definition tool. This drives me nuts because the developer ergonomics are terrible because of this.
This package provides a thin wrapper around SQLModel that provides a more ActiveRecord-like interface with things like:
- ActiveRecord-style Query & Persistence API: Fluent methods like
save(),where(),find_or_create_by(), andupsert()for intuitive database operations. - Implicit Session Management: Automatically handles database sessions, eliminating boilerplate and making database interactions feel "magic".
- Stripe-style IDs (TypeID): Native support for type-safe, prefixed, and sortable UUIDs with a built-in
TypeIDMixin. - Timestamp Column Mixins: Standard
created_atandupdated_attracking out of the box. - Lifecycle Hooks: Rails-style callbacks like
before_save,after_create, andaround_delete. - Automatic DB Comments: Syncs class and field-level docstrings directly to database table and column comments for better self-documentation.
- Soft Deletion: Easily mark records as deleted with a
deleted_attimestamp using theSoftDeletionMixin. - Smart Table & Constraint Naming: Consistent snake_case table names and standardized naming conventions for indexes and constraints.
[!TIP] This documentation is pretty bad. The tests and docstrs on code are the best way to learn how to use this.
Installation
uv add activemodel
Getting Started
First, setup your DB:
import activemodel
activemodel.init("sqlite:///database.db")
Create models:
from activemodel import BaseModel
from activemodel.mixins import TimestampsMixin, TypeIDMixin
class User(
BaseModel,
# optionally, obviously
TimestampsMixin,
# you can use a different pk type, but why would you?
# put this mixin last otherwise `id` will not be the first column in the DB
TypeIDMixin("user"),
# wire this model into the DB, without this alembic will not generate a migration
table=True
):
a_field: str
You'll need to create the models in the DB. Alembic is the best way to do it, but you can cheat as well:
from sqlmodel import SQLModel
SQLModel.metadata.create_all(get_engine())
# now you can create a user! without managing sessions!
User(a_field="a").save()
Maybe you like JSON:
from sqlalchemy.dialects.postgresql import JSONB
from pydantic import BaseModel as PydanticBaseModel
from activemodel import BaseModel
from activemodel.mixins import PydanticJSONMixin, TypeIDMixin, TimestampsMixin
class SubObject(PydanticBaseModel):
name: str
value: int
class User(
BaseModel,
TimestampsMixin,
PydanticJSONMixin,
TypeIDMixin("user"),
table=True
):
list_field: list[SubObject] = Field(sa_type=JSONB)
You'll probably want to query the model. Look ma, no sessions!
User.where(id="user_123").all()
# or, even better, for this case
User.one("user_123")
Magically creating sessions for DB operations is one of the main problems this project tackles. Even better, you can set a single session object to be used for all DB operations. This is helpful for DB transactions, specifically rolling back DB operations on each test.
Usage
Lifecycle Hooks
BaseModel supports a small Rails-style lifecycle hook system.
The implemented hooks today are:
- Create/update:
before_create,after_create,before_update,after_update,before_save,after_save,around_save - Delete:
before_delete,after_delete,around_delete - Read:
after_find,after_initialize
Hook methods are optional. If a method with one of those names exists on the model, ActiveModel will call it at the appropriate time.
from contextlib import contextmanager
from activemodel import BaseModel
class User(BaseModel, table=True):
id: int | None = Field(default=None, primary_key=True)
email: str
def before_save(self):
self.email = self.email.strip().lower()
def after_find(self):
print(f"loaded user {self.id}")
def after_initialize(self):
print(f"initialized user {self.id}")
@contextmanager
def around_save(self):
print("before save")
yield
print("after save")
Some important semantics:
after_initializeruns on plain construction, soUser(email="a@example.com")will trigger it even before the record is saved.- Database-backed finder/query loads run
after_findand thenafter_initialize. after_findis not called for plain construction.find_or_initialize_by()follows the Rails-style split: the existing-record path runsafter_findthenafter_initialize, while the new-instance path only runsafter_initialize.around_saveandaround_deletemust be context managers.
Current ordering is:
- Create:
before_create -> before_save -> around_save -> after_create -> after_save - Update:
before_update -> before_save -> around_save -> after_update -> after_save - Delete:
before_delete -> around_delete -> after_delete - DB load:
after_find -> after_initialize - Plain construction:
after_initialize
There is one important scope limit to know about:
refresh()does not currently replay Rails-style read callbacks. It refreshes the object from the database, but it does not currently triggerafter_find/after_initializethe way Railsreloadeffectively does.
Also note that after_find / after_initialize only run for model instances. Lower-level query paths that return None, counts, scalars, or raw SQLAlchemy result objects are outside that contract.
Pytest
TODO detail out truncation and transactions
Integrating Alembic
Detailed instructions on how to integrate Alembic into your project can be found in the Alembic Integration documentation.
Query Wrapper
This tool is added to all BaseModels and makes it easy to write SQL queries. Some examples:
Easy Database Sessions
I hate the idea f
- Behavior should be intuitive and easy to understand. If you run
save(), it should save, not stick the save in a transaction. - Don't worry about dead sessions. This makes it easy to lazy-load computed properties and largely eliminates the need to think about database sessions.
There are a couple of thorny problems we need to solve for here:
- In-memory fastapi servers are not the same as a uvicorn server, which is threaded and uses some sort of threadpool model for handling async requests. I don't claim to understand the entire implementation. For global DB session state (a) we can't use global variables (b) we can't use thread-local variables.iset
https://github.com/tomwojcik/starlette-context
Example SQLAlchemy Queries
- Conditional:
Scrape.select().where(Scrape.id < last_scraped.id).all() - Equality:
MenuItem.select().where(MenuItem.menu_id == menu.id).all() INexample:CanonicalMenuItem.select().where(col(CanonicalMenuItem.id).in_(canonized_ids)).all()- Compound where query:
User.where((User.last_active_at != None) & (User.last_active_at > last_24_hours)).count() - How to select a field in a JSONB column:
str(HostScreeningOrder.form_data["email"].as_string()) - JSONB where clause:
Screening.where(Screening.theater_location['name'].astext.ilike('%AMC%'))
SQLModel Internals
SQLModel & SQLAlchemy are tricky. Here are some useful internal tricks:
__sqlmodel_relationships__is where anyRelationshipInfoobjects are stored. This is used to generate relationship fields on the object.inspect(type(self)).relationships['distribution']to inspect a specific generated relationship objectModelClass.relationship_name.property.local_columns- Get cached fields from a model
object_state(instance).dict.get(field_name) - Set the value on a field, without marking it as dirty
attributes.set_committed_value(instance, field_name, val) - Is a model dirty
instance_state(instance).modified select(Table).outerjoin??won't work in a ipython session, butTable.__table__.outerjoin??will.__table__is a reference to the underlying SQLAlchemy table record.get_engine().pool.stats()is helpful for inspecting connection pools and limits\
TypeID
I'm a massive fan of Stripe-style prefixed UUIDs. There's an excellent project that defined a clear spec for these IDs. I've used the python implementation of this spec and developed a clean integration with SQLModel that plays well with fastapi as well.
Here's an example of defining a relationship:
import uuid
from activemodel import BaseModel
from activemodel.mixins import TimestampsMixin, TypeIDMixin
from activemodel.types import TypeIDType
from sqlmodel import Field, Relationship
from .patient import Patient
class Appointment(
BaseModel,
# this adds an `id` field to the model with the correct type
TypeIDMixin("appointment"),
table=True
):
# `foreign_key` is a activemodel method to generate the right `Field` for the relationship
# TypeIDType is really important here for fastapi serialization
doctor_id: TypeIDType = Doctor.foreign_key()
doctor: Doctor = Relationship()
Here's how to get the prefix associated with a given field:
model_class.__model__.model_fields["field_name"].sa_column.type.prefix
Limitations
Validation
SQLModel does not currently support pydantic validations (when table=True). This is very surprising, but is actually the intended functionality:
- https://github.com/fastapi/sqlmodel/discussions/897
- https://github.com/fastapi/sqlmodel/pull/1041
- https://github.com/fastapi/sqlmodel/issues/453
- https://github.com/fastapi/sqlmodel/issues/52#issuecomment-1311987732
For validation:
- When consuming API data, use a separate shadow model to validate the data with
table=Falseand then inherit from that model in a model withtable=True. - When validating ORM data, use SQL Alchemy hooks.
Development
Watch out for subtle differences across pydantic versions. There's some sneaky type inspection stuff in PydanticJSONMixin
that will break in subtle ways if the python, pydantic, etc versions don't match.
import pydantic
print(pydantic.VERSION)
import sys
print(sys.version)
Related Projects
- https://github.com/woofz/sqlmodel-basecrud
- https://github.com/0xthiagomartins/sqlmodel-controller
- https://github.com/litestar-org/advanced-alchemy
- https://github.com/dialoguemd/fastapi-sqla
Inspiration
- https://github.com/peterdresslar/fastapi-sqlmodel-alembic-pg
- Albemic instructions
- https://github.com/fastapiutils/fastapi-utils/
- https://github.com/fastapi/full-stack-fastapi-template
- https://github.com/DarylStark/my_data/
- https://github.com/petrgazarov/FastAPI-app/tree/main/fastapi_app
Upstream Changes
This project was created from iloveitaly/python-package-template
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file activemodel-0.19.0.tar.gz.
File metadata
- Download URL: activemodel-0.19.0.tar.gz
- Upload date:
- Size: 32.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.11.2 {"installer":{"name":"uv","version":"0.11.2","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
305b28e4e5a8b96b549f4532a7d9e5093ad9ddc4d337b322c92bb656cc404fe7
|
|
| MD5 |
99e630ad55c6e9feefb8c7560f09efef
|
|
| BLAKE2b-256 |
73e9d0b0e3d2f3b4d3686250817d0c1a8c45bc9dbadcf3dde90abace7e77b1e2
|
File details
Details for the file activemodel-0.19.0-py3-none-any.whl.
File metadata
- Download URL: activemodel-0.19.0-py3-none-any.whl
- Upload date:
- Size: 42.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.11.2 {"installer":{"name":"uv","version":"0.11.2","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5f26b2f02f1f4f6052bb29a997d567d815f7613e033b2c7e96e9245cbdf9db0c
|
|
| MD5 |
3aaf5edfc08a8e4b53a8944a1d8e79d9
|
|
| BLAKE2b-256 |
8e2e4e40927e20556b849e4831d5f1f52c15f955a68beee7cf86f2522796195c
|