Automatic relationship eager-loading for SQLAlchemy — LATERAL, dotted paths, self-refs, conditions
Project description
sqla-autoloads
Automatic relationship eager-loading for SQLAlchemy. Build SELECT queries with LATERAL subqueries, dotted-path traversal, self-referential handling, and per-relationship conditions — all from a single function call.
Install
pip install sqla-autoloads
Quick Start
from sqla_autoloads import init_node, get_node, sqla_select
# 1. Initialize once at startup
init_node(get_node(Base))
# 2. Query with eager-loading
query = sqla_select(model=User, loads=("posts", "roles"))
result = await session.execute(query)
users = result.unique().scalars().all()
# users[0].posts — already loaded, no N+1
Why?
Loading relationships in SQLAlchemy requires writing boilerplate joins and loader options for every combination of relationships. This grows quickly — especially with optional loads, LATERAL limits, and deep chains.
Raw SQLAlchemy — manual if per relationship:
UserLoad = Literal["posts", "roles", "profile"]
async def get_users(*loads: UserLoad) -> list[User]:
query = sa.select(User)
options = []
if "posts" in loads:
lateral = (
sa.select(Post)
.where(Post.author_id == User.id)
.order_by(Post.id.desc())
.limit(50)
.lateral()
)
query = query.outerjoin(lateral, sa.true())
options.append(orm.contains_eager(User.posts, alias=lateral))
if "roles" in loads:
query = query.outerjoin(user_roles).outerjoin(Role)
options.append(orm.contains_eager(User.roles))
if "profile" in loads:
query = query.outerjoin(Profile)
options.append(orm.contains_eager(User.profile))
if options:
query = query.options(*options)
return (await session.execute(query)).unique().scalars().all()
sqla-autoloads — one call, on-demand:
UserLoad = Literal["posts", "roles", "profile"]
async def get_users(*loads: UserLoad) -> list[User]:
query = sqla_select(model=User, loads=loads)
return (await session.execute(query)).unique().scalars().all()
Features
- LATERAL subqueries with configurable limit (default 50 per relationship)
- Dotted paths —
"posts.comments.reactions"traverses the chain automatically - Self-referential relationships — parent/children on the same model
- M2M through association tables
- Multiple FKs to the same table (e.g.
from_user,to_user,owner) - Per-relationship conditions via
add_conditions - Automatic strategy selection —
contains_eager,selectinload,joinedload,subqueryload - LRU-cached query construction — same parameters return the same compiled query
- Async-first, sync-compatible
- Fully typed (PEP 561
py.typedmarker)
Usage
Basic loads
# One-to-many
query = sqla_select(model=User, loads=("posts",))
# Many-to-many
query = sqla_select(model=User, loads=("roles",))
# Many-to-one / one-to-one
query = sqla_select(model=Post, loads=("author",))
# Multiple relationships at once
query = sqla_select(model=User, loads=("posts", "roles", "profile"))
Deep / dotted paths
# Load posts → comments → reactions in one query
query = sqla_select(model=User, loads=("posts.comments.reactions",))
Conditions
from sqla_autoloads import add_conditions
query = sqla_select(
model=User,
loads=("posts", "roles"),
conditions={
"roles": add_conditions(Role.level > 3),
},
)
Limit and ordering
# Custom limit per relationship (default is 50)
query = sqla_select(model=User, loads=("posts",), limit=10)
# No limit — load all related rows (uses subqueryload/selectinload)
query = sqla_select(model=User, loads=("posts",), limit=None)
# Custom ordering
query = sqla_select(model=User, loads=("posts",), order_by=("title",))
Self-referential
query = sqla_select(
model=Category,
loads=("children", "parent"),
self_key="parent_id",
)
Extending an existing query
base = sa.select(User).where(User.active == True)
query = sqla_select(model=User, loads=("posts",), query=base)
Many-load strategy
# Use selectinload instead of default subqueryload for limit=None
query = sqla_select(model=User, loads=("posts",), limit=None, many_load="selectinload")
Important notes
LATERAL support — database compatibility
The limit parameter (default 50) uses LATERAL subqueries to cap the number of related rows per parent. LATERAL is supported by PostgreSQL and MySQL 8.0+ only.
SQLite, MariaDB, and MSSQL do not support LATERAL. On these databases, pass limit=None to disable LATERAL and fall back to subqueryload/selectinload:
query = sqla_select(model=User, loads=("posts",), limit=None)
.unique() is required on results
sqla_select uses outerjoin + contains_eager and joinedload to load relationships. These strategies produce duplicate parent rows in the raw result (one row per related object). This is standard SQLAlchemy behavior.
You must call .unique() on the result before .scalars():
result = await session.execute(query)
users = result.unique().scalars().all()
Without .unique(), SQLAlchemy will raise an error or return duplicate objects.
Note: The
distinct=Trueparameter insqla_selectapplies SQL-levelDISTINCT, which deduplicates by column values. It does not replace.unique(), which deduplicates by object identity.
API Reference
| Function | Description |
|---|---|
sqla_select(**params) |
Build a SELECT with eager-loaded relationships |
init_node(mapping) |
Initialize the relationship graph singleton (call once at startup) |
get_node(Base) |
Extract relationship mapping from a declarative base |
add_conditions(*exprs) |
Create a condition function for filtering loaded relationships |
Node() |
Access the singleton relationship graph |
SelectBuilder |
Query builder class (used internally, also exported) |
sqla_select parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
model |
type[T] |
required | SQLAlchemy model class |
loads |
tuple[str, ...] |
() |
Relationship paths to eager-load |
limit |
int | None |
50 |
LATERAL limit per relationship (None = no limit) |
conditions |
Mapping[str, Callable] |
None |
Per-relationship WHERE conditions |
order_by |
tuple[str, ...] |
None |
Column names for ordering (default: PK desc) |
query |
sa.Select |
None |
Existing query to extend |
self_key |
str |
auto-detected | FK column for self-referential relationships |
many_load |
str |
"subqueryload" |
Strategy for limit=None: "subqueryload" or "selectinload" |
distinct |
bool |
False |
Apply DISTINCT to the query |
check_tables |
bool |
False |
Check for existing tables to avoid duplicate joins |
Requirements
- Python 3.10+
- SQLAlchemy 2.0+
License
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 sqla_autoloads-0.1.0.tar.gz.
File metadata
- Download URL: sqla_autoloads-0.1.0.tar.gz
- Upload date:
- Size: 105.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
842875deddd8c5701c5cc447205ccc3d8b580733f61098e3b4396535af42b5b4
|
|
| MD5 |
279717eb6071ebf38f40883cb4845ee6
|
|
| BLAKE2b-256 |
f5634e6892a6f93b63180345a8101c4a48e62d6818dd42579ae887aa14067d8a
|
File details
Details for the file sqla_autoloads-0.1.0-py3-none-any.whl.
File metadata
- Download URL: sqla_autoloads-0.1.0-py3-none-any.whl
- Upload date:
- Size: 17.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a8a80c25c54f93a7ebe1afb35f1148775359229b4aa60a1577d6c2616afd8936
|
|
| MD5 |
4affcfe8f59a6989ece215b058e3a018
|
|
| BLAKE2b-256 |
b394764717942b4c647c9619812670ed0d395759c0c8c5740caebae2f25e067a
|