A collection of type-safe, async friendly, and un-opinionated enhancements to SQLAlchemy Core that works well with mordern web servers.
Why?
- ORMs are magical, but it's not always a feature. Sometimes, we crave for familiar.
- SQLAlchemy Core is powerful but
table.c.columnbreaks static type checking and has runtime overhead. This library provides a better way to define tables while keeping all of SQLAlchemy's flexibility. See Table Builder. - The idea of sessions can feel too magical and opinionated. This library removes the magic and opinions and takes you to back to familiar transactions's territory, providing multiple un-opinionated APIs to deal with it. See Wrappers and Decorators.
Demos:
Define tables with static column references
Example:
Define tables:
import sqlalchemy as sa
from sqla_fancy_core import TableBuilder
tb = TableBuilder()
class Author:
id = tb.auto_id()
name = tb.string("name")
created_at = tb.created_at()
updated_at = tb.updated_at()
Table = tb("author")For complex scenarios, define columns and constraints explicitly:
class Book:
id = tb(sa.Column("id", sa.Integer, primary_key=True, autoincrement=True))
title = tb(sa.Column("title", sa.String(255), nullable=False))
author_id = tb(sa.Column("author_id", sa.Integer, sa.ForeignKey(Author.id)))
created_at = tb(
sa.Column(
"created_at",
sa.DateTime,
nullable=False,
server_default=sa.func.now(),
)
)
updated_at = tb(
sa.Column(
"updated_at",
sa.DateTime,
nullable=False,
server_default=sa.func.now(),
onupdate=sa.func.now(),
)
)
# Option 1: Define multi-column constraints inside the class
ux_author_title = tb(sa.UniqueConstraint(author_id, title))
Table = tb("book")
# Option 2: Define multi-column constraints when building the table
class BookAlt:
id = tb(sa.Column("id", sa.Integer, primary_key=True, autoincrement=True))
title = tb(sa.Column("title", sa.String(255), nullable=False))
author_id = tb(sa.Column("author_id", sa.Integer, sa.ForeignKey(Author.id)))
Table = tb("book_alt", sa.UniqueConstraint(author_id, title))Create tables:
from sqlalchemy.ext.asyncio import create_async_engine
# Create the engine
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
# Create the tables
async with engine.begin() as conn:
await conn.run_sync(tb.metadata.create_all)Perform CRUD operations:
async with engine.begin() as txn:
# Insert author
qry = (
sa.insert(Author.Table)
.values({Author.name: "John Doe"})
.returning(Author.id)
)
author = (await txn.execute(qry)).mappings().one()
author_id = author[Author.id]
assert author_id == 1
# Insert book
qry = (
sa.insert(Book.Table)
.values({Book.title: "My Book", Book.author_id: author_id})
.returning(Book.id)
)
book = (await txn.execute(qry)).mappings().one()
assert book[Book.id] == 1
# Query the data
qry = sa.select(Author.name, Book.title).join(
Book.Table,
Book.author_id == Author.id,
)
result = (await txn.execute(qry)).all()
assert result == [("John Doe", "My Book")], resultSimplify running queries with or without explicit connection or transaction. The fancy() function wraps a SQLAlchemy engine and provides:
tx(conn, query): Execute query in optional transaction (reusesatomic()context if available, else creates new)atomic(): Context manager for transaction scopeax(query): Execute insideatomic()context (raisesAtomicContextErroroutside)atx(query): Auto-transactional (reusesatomic()if present, or creates new transaction)
x(conn, query): Execute query with optional connection (reusesnon_atomic()oratomic()context if available, else creates new)non_atomic(): Context manager for non-transactional connections (allows manual transaction control)nax(query): Execute insidenon_atomic()oratomic()context or create a new connection
Sync Example:
import sqlalchemy as sa
from sqla_fancy_core import fancy
engine = sa.create_engine("sqlite:///:memory:")
fancy_engine = fancy(engine)
def get_data(conn: sa.Connection | None = None):
return fancy_engine.tx(conn, sa.select(sa.literal(1))).scalar_one()
# Without an explicit transaction
assert get_data() == 1
# With an explicit transaction
with engine.begin() as conn:
assert get_data(conn) == 1Async Example:
import sqlalchemy as sa
from sqlalchemy.ext.asyncio import create_async_engine, AsyncConnection
from sqla_fancy_core import fancy
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
fancy_engine = fancy(engine)
async def get_data(conn: AsyncConnection | None = None):
result = await fancy_engine.x(conn, sa.select(sa.literal(1)))
return result.scalar_one()
# Without an explicit connection
assert await get_data() == 1
# With an explicit connection
async with engine.connect() as conn:
assert await get_data(conn) == 1Group operations in a single transaction without passing around the Connection/AsyncConnection instance. Nested atomic() contexts share the outer connection.
Sync Example:
import sqlalchemy as sa
from sqla_fancy_core import fancy, TableBuilder
tb = TableBuilder()
class User:
id = tb.auto_id()
name = tb.string("name")
Table = tb("users")
engine = sa.create_engine("sqlite:///:memory:")
tb.metadata.create_all(engine)
fancy_engine = fancy(engine)
# Group operations in one transaction
with fancy_engine.atomic():
fancy_engine.ax(sa.insert(User.Table).values(name="Alice"))
fancy_engine.ax(sa.insert(User.Table).values(name="Bob"))
result = fancy_engine.ax(sa.select(sa.func.count()).select_from(User.Table))
count = result.scalar_one()
assert count == 2Async Example:
import sqlalchemy as sa
from sqlalchemy.ext.asyncio import create_async_engine
from sqla_fancy_core import fancy, TableBuilder
tb = TableBuilder()
class User:
id = tb.auto_id()
name = tb.string("name")
Table = tb("users")
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(tb.metadata.create_all)
fancy_engine = fancy(engine)
async with fancy_engine.atomic():
await fancy_engine.ax(sa.insert(User.Table).values(name="Alice"))
await fancy_engine.ax(sa.insert(User.Table).values(name="Bob"))
result = await fancy_engine.ax(sa.select(sa.func.count()).select_from(User.Table))
count = result.scalar_one()
assert count == 2For cases where you need manual transaction control or want to perform read operations with connection reuse:
Sync Example:
import sqlalchemy as sa
from sqla_fancy_core import fancy, TableBuilder
tb = TableBuilder()
class User:
id = tb.auto_id()
name = tb.string("name")
Table = tb("users")
engine = sa.create_engine("sqlite:///:memory:")
tb.metadata.create_all(engine)
fancy_engine = fancy(engine)
# Reuse connection without automatic transaction
with fancy_engine.non_atomic() as conn:
# You can start manual transactions if you want
with conn.begin():
fancy_engine.nax(sa.insert(User.Table).values(name="Alice"))
conn.commit() # Explicit commit
# Or perform multiple reads with the same connection
result1 = fancy_engine.nax(sa.select(User.name).where(User.id == 1))
result2 = fancy_engine.nax(sa.select(sa.func.count()).select_from(User.Table))Async Example:
import sqlalchemy as sa
from sqlalchemy.ext.asyncio import create_async_engine
from sqla_fancy_core import fancy, TableBuilder
tb = TableBuilder()
class User:
id = tb.auto_id()
name = tb.string("name")
Table = tb("users")
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(tb.metadata.create_all)
fancy_engine = fancy(engine)
# Reuse connection for multiple operations
async with fancy_engine.non_atomic() as conn:
# Manual transaction control
async with conn.begin():
await fancy_engine.nax(sa.insert(User.Table).values(name="Alice"))
await conn.commit()
# Or perform reads
result = await fancy_engine.nax(sa.select(User.name).where(User.id == 1))
name = result.scalar_one()
assert name == "Alice"Keep functions connection-agnostic with decorator-based injection.
Components:
Inject(engine): Marks parameter for connection injection@connect: Ensures live connection (no transaction by default)@transact: Ensures transactional connection
Use @connect for read-only operations. Use @transact for writes.
import sqlalchemy as sa
from sqla_fancy_core import Inject, connect, transact, TableBuilder
tb = TableBuilder()
class User:
id = tb.auto_id()
name = tb.string("name")
Table = tb("users")
engine = sa.create_engine("sqlite:///:memory:")
tb.metadata.create_all(engine)
@connect
def get_user_count(conn=Inject(engine)):
return conn.execute(sa.select(sa.func.count()).select_from(User.Table)).scalar_one()
assert get_user_count() == 0
@transact
def create_user(name: str, conn=Inject(engine)):
conn.execute(sa.insert(User.Table).values({User.name: name}))
# Without an explicit transaction
create_user("alice")
assert get_user_count() == 1
# With an explicit transaction
with engine.begin() as txn:
create_user("bob", conn=txn)
assert get_user_count(conn=txn) == 2import sqlalchemy as sa
from sqlalchemy.ext.asyncio import create_async_engine, AsyncConnection
from sqla_fancy_core import Inject, connect, transact, TableBuilder
tb = TableBuilder()
class User:
id = tb.auto_id()
name = tb.string("name")
Table = tb("users")
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(tb.metadata.create_all)
@connect
async def get_user_count(conn=Inject(engine)):
result = await conn.execute(sa.select(sa.func.count()).select_from(User.Table))
return result.scalar_one()
@transact
async def create_user(name: str, conn=Inject(engine)):
await conn.execute(sa.insert(User.Table).values({User.name: name}))
# Without an explicit transaction
assert await get_user_count() == 0
await create_user("carol")
assert await get_user_count() == 1
# With an explicit transaction
async with engine.begin() as conn:
await create_user("dave", conn=conn)
assert await get_user_count(conn=conn) == 2Also works with dependency injection frameworks like FastAPI:
from typing import Annotated
from fastapi import Depends, FastAPI, Form
import sqlalchemy as sa
from sqla_fancy_core import Inject, transact
app = FastAPI()
def get_transaction():
with engine.begin() as conn:
yield conn
@transact
def create_user(
name: Annotated[str, Form(...)],
conn: Annotated[sa.Connection, Depends(get_transaction)] = Inject(engine),
):
conn.execute(sa.insert(User.Table).values({User.name: name}))
# Register route
app.post("/create-user")(create_user)
# Works outside FastAPI too — starts its own transaction
create_user(name="outside fastapi")Async with FastAPI:
from typing import Annotated
from fastapi import Depends, FastAPI, Form
from sqlalchemy.ext.asyncio import AsyncConnection
import sqlalchemy as sa
from sqla_fancy_core import Inject, transact
app = FastAPI()
async def get_transaction():
async with engine.begin() as conn:
yield conn
@transact
@app.post("/create-user")
async def create_user(
name: Annotated[str, Form(...)],
conn: Annotated[AsyncConnection, Depends(get_transaction)] = Inject(engine),
):
await conn.execute(sa.insert(User.Table).values({User.name: name}))
# Works outside FastAPI too — starts its own transaction
await create_user(name="outside fastapi")If you like to define validation logic in the column itself, this is one way to do it:
from typing import Any
import sqlalchemy as sa
from pydantic import BaseModel, Field
import pytest
from sqla_fancy_core import TableBuilder
tb = TableBuilder()
def field(col, default: Any = ...) -> Field:
return col.info["kwargs"]["field"](default)
# Define a table
class User:
name = tb(
sa.Column("name", sa.String),
field=lambda default: Field(default, max_length=5),
)
Table = tb("author")
# Define a pydantic schema
class CreateUser(BaseModel):
name: str = field(User.name)
# Define a pydantic schema
class UpdateUser(BaseModel):
name: str | None = field(User.name, None)
assert CreateUser(name="John").model_dump() == {"name": "John"}
assert UpdateUser(name="John").model_dump() == {"name": "John"}
assert UpdateUser().model_dump(exclude_unset=True) == {}
with pytest.raises(ValueError):
CreateUser()
with pytest.raises(ValueError):
UpdateUser(name="John Doe")Production. For folks who prefer query maker over ORM, looking for a robust sync/async driver integration, wanting to keep code readable and secure.
Peewee: No type hints. Also, no official async support.
Piccolo: Tight integration with drivers. Very opinionated. Not as flexible or mature as sqlalchemy core.
Pypika: Doesn’t prevent sql injection by default. Hence can be considered insecure.
Raw string queries with placeholders: sacrifices code readability, and prone to sql injection if one forgets to use placeholders.
Other ORMs: They are full blown ORMs, not query makers.