ORM
Ushka ships an async ORM built on SQLAlchemy 2.0 with a small Active Record
interface: models know how to save, delete and query themselves. Migrations
are handled by Alembic through ushka db.
Installing
The ORM is an optional extra, so the core install stays small:
pip install "ushka[orm]" # SQLAlchemy, Alembic and the SQLite driver (aiosqlite)
pip install "ushka[postgres]" # + asyncpg
pip install "ushka[mysql]" # + aiomysql
Choosing the database
The URL comes from the configuration, in this order of
precedence: environment / .env (USHKA_DATABASE_URL, or DATABASE_URL, or
the older USHKA_DB_URL), then [database] url in ushka.toml, then the
default sqlite:///ushka.db.
# ushka.toml
[database]
url = "sqlite:///shop.db"
# .env, keep credentials out of ushka.toml
USHKA_DATABASE_URL=postgresql://user:password@localhost/shop
Write plain URLs: sqlite://, postgresql:// (or postgres://) and
mysql:// are switched to their async drivers (aiosqlite, asyncpg,
aiomysql) automatically. A URL that already names a driver
(postgresql+psycopg://...) is used as is.
A relative SQLite path such as sqlite:///shop.db is relative to the directory
the process runs in, so start the server (and ushka db) from the project
directory, or use an absolute path: sqlite:////srv/shop/shop.db.
The engine is created lazily on first use. To point the ORM somewhere else from
code (in tests, for example) call db.configure(url) before the first query.
Defining models
# models.py
import enum
from ushka.orm import db
class Status(enum.Enum):
DRAFT = "draft"
PUBLISHED = "published"
class User(db.Model):
__tablename__ = "users"
name = db.String(length=100)
email = db.String(unique=True)
is_active = db.Bool(default=True)
created_at = db.DateTime(auto_now=True)
posts = db.Relationship("Post", back_populates="author")
class Post(db.Model):
__tablename__ = "posts"
title = db.String(length=200)
body = db.Text(nullable=True)
status = db.Enum(Status, default=Status.DRAFT)
tags = db.JSON(default=list)
author_id = db.ForeignKey("users.id")
author = db.Relationship("User", back_populates="posts")
Every model needs a __tablename__. Do not declare id: db.Model already
provides an auto-incrementing BigInteger primary key named id.
Column helpers
Columns are NOT NULL unless you pass nullable=True.
| Helper | Signature |
|---|---|
db.String |
(length=255, unique=False, nullable=False, default=None, choices=None). choices=[...] restricts the values. |
db.Text |
(nullable=False, default=None) |
db.Int |
(unique=False, nullable=False, default=None) |
db.BigInt |
(unique=False, nullable=False, default=None) |
db.Float |
(nullable=False, default=0.0) |
db.Decimal |
(precision=10, scale=2, nullable=False, default=0) |
db.Bool |
(default=False, nullable=False) |
db.DateTime |
(auto_now=False, nullable=False). auto_now=True defaults to the current time on insert. |
db.Date |
(auto_now=False, nullable=False) |
db.Time |
(nullable=False) |
db.JSON |
(nullable=False, default=None). Defaults to a fresh {} per row; a dict/list default is copied per row. |
db.Enum |
(enum_cls, nullable=False, default=None) |
db.Binary |
(length=None, nullable=False) |
db.UUID |
(unique=False, nullable=False, primary_key=False, default=None). With primary_key=True the default is uuid.uuid4. |
db.ForeignKey |
(target, dtype=None, on_delete=db.CASCADE, nullable=False). target is "table.column"; the type defaults to BigInteger. on_delete is db.CASCADE, db.SET_NULL (makes the column nullable) or db.RESTRICT. |
db.Relationship |
(model, back_populates=None). model is the class name as a string. |
db.Model is a regular SQLAlchemy declarative base, so plain SQLAlchemy
Column(...) definitions work as well.
Creating the tables: migrations
Tables are created and changed through migrations. Alembic has to see your
models, and it looks for them by importing app.models, app or main from
the project directory, so import your models in app.py:
# app.py
from ushka import Ushka
import models # noqa: F401 (registers the models for `ushka db make`)
app = Ushka()
Then, from the project directory:
ushka db init # once: creates migrations/ and alembic.ini
ushka db make -m "create users" # generates a migration from your models
ushka db migrate # applies it
Every time you change a model, run ushka db make -m "..." and
ushka db migrate again. ushka db revert undoes the last migration. Review
the generated file in migrations/versions/ before applying it. The full list
of commands is in the CLI guide.
SQLite migrations run in Alembic's batch mode automatically, so columns can be altered and dropped there too.
CRUD
All database methods are coroutines: remember the await.
Create
user = await User.create(name="Alice", email="alice@example.com")
bob = User(name="Bob", email="bob@example.com")
await bob.save()
Read
users = await User.all() # list
user = await User.get(1) # by primary key, or None
alice = await User.first(name="Alice") # first match, or None
active = await User.filter(is_active=True) # list of matches
first() and filter() take keyword arguments and compare for equality.
For anything richer, build a SQLAlchemy statement with Model.select() and run
it with db.execute():
result = await db.execute(
User.select().where(User.email.endswith("@example.com")).order_by(User.name)
)
users = result.scalars().all()
Update
user = await User.get(1)
if user:
user.name = "Alicia"
await user.save()
Delete
user = await User.first(name="Bob")
if user:
await user.delete()
Serialising
instance.to_dict() returns the columns as a dict (enums become their value,
UUIDs become strings). Dates and times stay datetime objects, so convert them
before returning the dict as JSON:
data = user.to_dict()
data["created_at"] = user.created_at.isoformat()
return data
Request data and the ORM
Values from the request are Untrusted strings. The ORM methods and SQLAlchemy
expressions always send them as bound parameters, so they need no special care,
but convert types yourself:
# routes/users/[user_id].py
from ushka.guard import safe
from ushka.http.exceptions import HttpNotFound
from models import User
async def get(user_id):
user = await User.get(safe.integer(user_id, min=1))
if user is None:
raise HttpNotFound(message="No such user")
return {"id": user.id, "name": user.name}
Raw SQL
db.execute() accepts a SQLAlchemy construct, a SQL string, or (on Python
3.14+) a t-string. SQL strings are guarded: if any part came from the request,
the call is refused with USHKA-G005. Pass values as
parameters instead:
# :name placeholders, values bound by the driver
result = await db.execute(
db.text("SELECT id, name FROM users WHERE email = :email", email=email)
)
rows = result.all()
# Python 3.14+: every {value} in a t-string becomes a bound parameter
result = await db.execute(t"SELECT id, name FROM users WHERE email = {email}")
Use db.text() rather than sqlalchemy.text(): SQLAlchemy copies the string
internally and the guard would lose track of where it came from.
Relationships and sessions
The Active Record methods open a short session per call, so the objects they
return are detached. Accessing a relationship that was not loaded
(post.author) then raises DetachedInstanceError. Load it up front:
from sqlalchemy.orm import selectinload
result = await db.execute(Post.select().options(selectinload(Post.author)))
for post in result.scalars().all():
print(post.title, post.author.name)
For several operations in one transaction, open a session yourself:
async with db.Session() as session:
session.add_all([User(name="Ann", email="ann@example.com"),
User(name="Ben", email="ben@example.com")])
await session.commit()
db.engine (the AsyncEngine) and db.metadata are available for anything
else SQLAlchemy can do.