An opinionated, modern ORM for Python combining the power of SQLAlchemy 2.0 with a clean, symmetrical API for sync and async operations.
Project description
DuoORM
An opinionated, modern ORM for Python combining the power of SQLAlchemy 2.0 with a clean, symmetrical API for sync and async operations.
Core Philosophy
DuoORM is built on a simple idea: you are in explicit control of the 'Unit of Work'. The API has two predictable modes and each Database(...) you instantiate manufactures its own db.Model base. Models from db1.Model and db2.Model stay isolated—even if they point to the same physical database—so you can safely manage multiple connections in one app (just keep each model hierarchy tied to its owning db).
-
Default Mode ("Statement-Driven"): By default, every call (
.save(),.first()) is its own "micro-transaction." The ORM creates a short-lived session that only knows about the single object you are operating on. It will predictably not see related objects, guaranteeing a simple, single-statement operation. -
Transaction Mode ("State-Driven"): When you use
async with db.transaction():, you "opt-in" to a full-power, state-tracking Unit of Work. The ORM will use a single shared session for the entire block, allowing you to work with multiple objects, relationships, and cascades, all of which will be committed or rolled back together.
Philosophy: Clarity over cleverness. The 'magic' of state-tracking only happens when you explicitly ask for it.
Session & Transaction Model
There is no autosession detection logic. By default each ORM call opens a connection, runs exactly one SQL statement, commits (for writes), and closes. When you need multi-step or related work to behave atomically, you wrap it in an explicit transaction block:
async with db.transaction():
user = await User.where(User.id == 1).first()
post = Post(title="Hello", author_id=user.id)
await post.save()
Inside the block your code automatically shares a single session. The block commits when it exits successfully and rolls back on error, giving you predictable Unit-of-Work semantics only when you opt-in.
Getting Started
1. Installation
Drivers are managed automatically. Supply only base dialect URLs (e.g., postgresql://..., mysql://..., sqlite:///file.db) — do not include +driver; we inject the correct sync/async driver for you. If you pass extra query parameters that your DBAPI rejects, the error will surface directly from the driver so you can fix the URL.
# Default: install with SQLite support (stdlib sqlite3 + aiosqlite)
pip install duo-orm
# Optional: install only the drivers you need
pip install duo-orm[postgresql] # psycopg (sync+async)
pip install duo-orm[mysql] # pymysql (sync) + asyncmy (async)
pip install duo-orm[mssql] # pyodbc (sync) + aioodbc (async)
pip install duo-orm[oracle] # oracledb (sync+async)
pip install duo-orm[all] # explicitly install everything
Need a stdlib SQLite fallback? See the SQLite note below:
SQLite fallback (only if your Python lacks stdlib sqlite3, e.g., minimal Docker/Lambda):
pip install pysqlite3-binaryThen alias it once at startup:
import sys, pysqlite3 sys.modules["sqlite3"] = pysqlite3This makes
import sqlite3use the binary fallback.
Async derivation uses the async driver for your dialect (psycopg for Postgres, asyncmy for MySQL, aioodbc for MSSQL, aiosqlite for SQLite). If the async driver isn’t installed, async calls will fail—install the matching extra above or duo-orm[all].
DuoORM rides on SQLAlchemy’s dialect support. Features are available per backend only if SQLAlchemy exposes them (e.g., full JSON/ARRAY helpers on PostgreSQL; other dialects allow JSON storage but not the richer operators, so related tests are skipped with a reason).
2. Initialization
Run the init command to create the basic structure:
duo-orm init
By default, scaffolding lands under <project-root>/db/:
db/
├── database.py
├── models/
│ └── __init__.py
└── migrations/
├── alembic.ini
├── env.py
├── script.py.mako
└── versions/
duo-orm init also creates (or updates) <project-root>/pyproject.toml so it contains:
[tool.duo-orm]
duo_orm_dir = "db"
Need a different location? Pass --dir during init:
duo-orm init --dir src/app/db_core
duo-orm migration create "add users"
The chosen path is written back to pyproject.toml, so future duo-orm migration ... commands can omit --dir. Edit that stanza (or re-run init --dir ...) whenever you want to move the database stack.
Optional: call db.connect() during application startup (after importing your generated database.py) if you want to surface misconfiguration or driver issues immediately rather than waiting for the first query/transaction.
3. Defining Models
Define your models in the models directory, inheriting from db.Model:
# models/user.py
from ..database import db
from duo_orm import Mapped, mapped_column
class User(db.Model):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str]
age: Mapped[int]
4. Creating Migrations
Once you have defined your models, create a migration to apply the schema to your database:
duo-orm migration create "initial models"
duo-orm migration upgrade
5. Basic Usage
import asyncio
from .database import db
from .models import User
async def main():
# Simple writes
user = User(name="Alice", age=30)
await user.save() # INSERT
# Simple reads with Pythonic operators
users = await User.where(User.age > 25).all()
alice = await User.where(User.name == "Alice").first()
# More complex queries
users = await User.where(
(User.age > 30) & User.name.startswith('A')
).all()
# Updates
alice.age = 31
await alice.save() # UPDATE
# Deletes
await alice.delete() # DELETE
if __name__ == "__main__":
asyncio.run(main())
Framework Integration
DuoORM plays well with FastAPI (and any async framework) by wrapping each request in a dependency that opens a transaction block:
from fastapi import Depends, FastAPI
from .database import db
app = FastAPI()
async def db_session():
async with db.transaction():
yield
@app.get("/users/{user_id}")
async def read_user(user_id: int, _=Depends(db_session)):
return await User.where(User.id == user_id).first()
All queries issued inside the request share the same session; pending writes persist until the response and exceptions roll the block back automatically.
Power User Access
When you need raw SQLAlchemy control, opt into a standalone session:
async with db.standalone_session() as session:
stmt = User.where(User.age > 30).alchemize()
rows = (await session.scalars(stmt)).all()
db.standalone_session() returns a plain AsyncSession, so you can use Core queries, streaming, or bulk inserts without leaving the DuoORM ecosystem.
Behaviour Summary
| Context | Session lifetime | Commit model | Typical use |
|---|---|---|---|
Default call (await User.where(...).first()) |
Short-lived per call | Auto-commit / auto-close | Scripts, simple reads & writes |
db.transaction() block |
Shared for the block | Commit on exit, rollback on error | Web requests, multi-step workflows |
db.standalone_session() |
Manual | You commit / rollback | Power users, raw SQLAlchemy |
Developer Experience Principles
- Simple:
await Model.query()just works—no session juggling for one-off calls. - Safe: One statement per operation unless you explicitly opt into a transaction.
- Symmetric: Sync and async APIs mirror each other (drop
awaitfor sync code). - Predictable: No hidden flushes, dirty graphs, or autosession guessing.
- Framework-agnostic: Works the same in FastAPI, CLIs, scripts, or the REPL.
Validation & Model Flags
Model.validate()– override this hook to enforce business rules; raiseValidationError(field="age", message)to blocksave()/bulk_create().Model.fields()– returns the tuple of column names defined on the model so you can introspect schemas at runtime.Model.to_dict()– serializes the instance into a plain dictionary of column values for JSON responses, logging, etc.ValidationError– now part of the public API and carries optionalfieldanddetailmetadata so you can surface meaningful errors to clients.info={"set_on": "create"}/info={"set_on": {"create","update"}}– declaratively control when a column is stamped (insert only vs. insert + every save). For compatibility we still honorauto_now_add/auto_nowflags.created_at = mapped_column(DateTime(timezone=True), info={"set_on": "create"}) updated_at = mapped_column(DateTime(timezone=True), info={"set_on": {"create", "update"}})
Query Enhancements
QueryBuilder.one()– fetch exactly one record (raisesObjectNotFoundError/MultipleObjectsFoundErrorlike SQLAlchemy).QueryBuilder.exists()– returnsTrue/Falsewithout materializing rows.QueryBuilder.paginate(limit, offset=0)– oneliner to apply both LIMIT and OFFSET.QueryBuilder.related(User.posts, where=[...], aggregate="exists", loader="selectin")– single entry point for filtering/aggregating/eager-loading across one direct relationship. Callingrelated()multiple times or with multi-hop paths (e.g.,User.posts.comments) isn’t supported; fall back to SQLAlchemy expressions for those cases.json(User.profile)["flags"]["beta"].is_null()– Pythonic JSON-path helper that compiles to regular SQLAlchemy expressions, so you can write complex JSON predicates inside.where(...)without juggling driver-specific operators.array(User.tags).includes_any(["python", "orm"])– expressive ARRAY helper for membership / superset / overlap checks without memorizing dialect-specific operators.- All helpers work in sync and async contexts just like
.first()and.all().
Why SQLAlchemy Underneath?
DuoORM stands on SQLAlchemy Core for query generation, schema metadata, type handling, and async engines. It deliberately avoids SQLAlchemy’s ORM layer so it stays lightweight, async-first, and free from invisible Unit-of-Work behavior. When you need to drop down, you already have a real AsyncSession in hand.
Query Operators
Basic Comparison
| Operator | Example |
|---|---|
== |
User.name == "Alice" |
!= |
User.status != "inactive" |
> |
User.age > 25 |
< |
User.age < 60 |
>= |
User.salary >= 50000 |
<= |
User.created_at <= date |
Membership & Pattern Matching
| Method | Example |
|---|---|
.in_([...]) |
User.country.in_(['IE', 'IN']) |
.notin_([...]) |
User.role.notin_(['banned', 'test']) |
.contains(value) |
User.email.contains('@example.com') |
.icontains(value) |
User.email.icontains('@example.com') |
.startswith(prefix) |
User.name.startswith('Al') |
.istartswith(prefix) |
User.name.istartswith('al') |
.iendswith(suffix) |
User.slug.iendswith('-beta') |
Case-insensitive helpers (.icontains, .istartswith, .iendswith) are provided by DuoORM and work on any column whose SQLAlchemy type derives from String. For custom wildcard patterns you can still fall back to SQLAlchemy’s .like() / .ilike(), but the ergonomic helpers cover 80% of real-world use.
JSON Path Helper
Use the built-in json() helper to compose JSON predicates that drop straight into where():
from duo_orm import json
await User.where(
json(User.profile)["flags"]["beta"].is_null() |
json(User.profile)["flags"]["beta"].equals("")
).all()
It mirrors normal Python dict access ([...]) and exposes fluent helpers that emit SQLAlchemy clauses:
| Helper | Purpose | Example |
|---|---|---|
json(col)["key"] |
Navigate nested keys/indices | json(User.profile)["flags"]["beta"] |
.equals(value) / == |
Compare scalars (auto-casts to text unless you call .as_integer() etc.) |
json(User.profile)["plan"] == "pro" |
.contains(fragment) |
JSON containment (@>-style) for dict/list fragments |
json(User.profile).contains({"plan": "enterprise"}) |
.has_key(key) |
Key existence (dialect-dependent; raises if unsupported) | json(User.profile)["flags"].has_key("beta") |
.is_null() / .is_not_null() |
Null checks on the selected path | json(User.profile)["expires_at"].is_null() |
.as_integer() / .as_float() / .as_boolean() / .as_text() |
Cast before comparisons | json(User.profile)["quota"].as_integer() > 10 |
Because the helper returns ordinary SQLAlchemy expressions, you can combine them with &, |, not_, nest them alongside other filters, and even extract the raw expression via .expression(). Dialect support is enforced by SQLAlchemy—if the backend lacks a JSON operator (e.g., has_key on SQLite), you’ll get a clear error at query construction time.
Practical note: full JSON path/operator helpers are available on PostgreSQL today. Other dialects store JSON fine but lack operator parity, so related tests are skipped with an explicit reason until dialect-specific compilers are added.
Array Helper
For ARRAY columns, reach for the array() helper:
from duo_orm import array
await User.where(
array(User.tags).includes("orm") &
array(User.tags).includes_all(["python", "asyncio"])
).all()
Helper methods map directly to common set-style checks:
| Helper | Purpose | Example |
|---|---|---|
.includes(value) |
True if the array contains that single value (value = ANY(column)). |
array(User.tags).includes("orm") |
.includes_all(values) |
True if the array contains all provided values (@>). |
array(User.tags).includes_all(["python","orm"]) |
.includes_any(values) |
True if the array overlaps any provided value (&&). |
array(User.tags).includes_any(["java","go"]) |
.length() |
Number of elements (uses cardinality() when available, otherwise array_length(..., 1)). |
array(User.tags).length() > 2 |
These helpers return SQLAlchemy expressions, so they compose with the rest of your filters exactly like built-in clauses. SQLAlchemy/dialect support rules still apply—unsupported ARRAY operators raise the driver’s error (or the helper re-raises with a clearer message).
Practical note: ARRAY helpers are PostgreSQL-only right now; other dialects do not expose compatible ARRAY operators. Tests are skipped with a reason when the backend lacks support.
Logical Combinators
| Operator | Example |
|---|---|
& |
(User.age > 30) & (User.status == 'active') |
| ` | ` |
Sync scripts or workers
The same db.transaction() helper can be used in synchronous code—just drop the await and use a plain with:
from .database import db
from .models import User
with db.transaction():
user = User(name="sync-flow")
user.save()
with db.transaction():
assert User.where(User.name == "sync-flow").exists()
Behind the scenes we detect whether you’re in an event loop and return either the async or sync context manager automatically, so you get one API for both worlds.
Testing
The test suite expects an explicit --db-url and never falls back to a default. Run tests against the backend you care about, for example:
pytest --db-url "sqlite:///./test.sqlite"
pytest --db-url "postgresql://user:pass@host:5432/dbname"
JSON/ARRAY helper tests are skipped with explicit reasons on dialects that lack SQLAlchemy operator support.
Project details
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 duo_orm-0.1.0.tar.gz.
File metadata
- Download URL: duo_orm-0.1.0.tar.gz
- Upload date:
- Size: 38.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ce97ea23f6ec4a325583bead8679cf1362782aea00ad5507ef3f4017bbb5c393
|
|
| MD5 |
aa09fcd2cdd10bff6f113aeea09c3bac
|
|
| BLAKE2b-256 |
ceb96134da9c00966f63f524ababb4b64d3ae86f4ebd3aa341727425e47aaca8
|
File details
Details for the file duo_orm-0.1.0-py3-none-any.whl.
File metadata
- Download URL: duo_orm-0.1.0-py3-none-any.whl
- Upload date:
- Size: 28.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
010d3e3635fbba5527d806201d39d621585cb10978e38df1e4117dc128b6eac0
|
|
| MD5 |
cf0e0f23198b18d745bbdd4b261b08e0
|
|
| BLAKE2b-256 |
f0bb4a1ee61b9d7c223a266a9067e9ee8d864189f567fa51303f956d3d44f4ab
|