Skip to main content

Automatic relationship eager-loading for SQLAlchemy — LATERAL, dotted paths, self-refs, conditions

Project description

sqla-autoloads

CI PyPI version Python License: MIT

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, unique_scalars

# 1. Initialize once at startup
init_node(get_node(Base))

# 2. Query with eager-loading
query = sqla_select(model=User, loads=("posts", "roles"))
users = unique_scalars(await session.execute(query))
# 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 unique_scalars(await session.execute(query))

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 unique_scalars(await session.execute(query))

Features

  • LATERAL subqueries with configurable limit (default 50 per relationship)
  • ZIP optimization — when 2+ sibling LATERAL subqueries exist at any depth, aligns them via ROW_NUMBER to prevent row multiplication (cross-product elimination)
  • Dotted paths"posts.comments.reactions" traverses the chain automatically
  • Self-referential relationships — parent/children on the same model
  • M2M through association tables (including M2M + direct O2M to the same table)
  • Multiple FKs to the same table (e.g. from_user, to_user, owner)
  • Per-relationship conditions via add_conditions
  • Automatic strategy selectioncontains_eager, selectinload, joinedload, subqueryload
  • LRU-cached query construction — same parameters return the same compiled query
  • Async-first, sync-compatible
  • Fully typed (PEP 561 py.typed marker)

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"))

M2M + direct O2M to same association table

# When Post has both tags (M2M via post_tags) and post_tags (O2M to PostTag),
# a single LATERAL subquery is shared — no duplicate joins:
query = sqla_select(
    model=Post,
    loads=("tags", "post_tags"),
    check_tables=True,
)

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(). Use the unique_scalars helper:

from sqla_autoloads import unique_scalars

users = unique_scalars(await session.execute(query))

Or manually: result.unique().scalars().all().

Without .unique(), SQLAlchemy will raise an error or return duplicate objects.

Note: The distinct=True parameter in sqla_select applies SQL-level DISTINCT, which deduplicates by column values. It does not replace .unique(), which deduplicates by object identity.

Filtering with .where() after sqla_select

When sqla_select uses LATERAL subqueries, table names in the FROM clause become LATERAL aliases. This means Model.column references may not resolve correctly in .where(). Use resolve_col to get a bound column element:

from sqla_autoloads import resolve_col, sqla_laterals

query = sqla_select(model=User, loads=("posts",))
col = resolve_col(query, "posts.title")
query = query.where(col == "hello")

# Discover available alias names:
print(sqla_laterals(query))  # {"posts": <Lateral ...>}

Alternatively, use sa.literal_column("alias.column"). See FAQ.md for naming rules and examples.

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
unique_scalars(result) Shorthand for result.unique().scalars().all()
Node() Access the singleton relationship graph
resolve_col(query, "alias.col") Resolve a LATERAL alias column to a bound ColumnElement
sqla_laterals(query) Return {name: Lateral} dict for all LATERAL joins in query
sqla_cache_info() Return LRU cache statistics for all internal caches
sqla_cache_clear() Clear all internal LRU caches
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
optimization bool True Enable ZIP optimization (ROW_NUMBER alignment for sibling LATERALs)
check_tables bool False Check for existing tables to avoid duplicate joins

Requirements

  • Python 3.10+
  • SQLAlchemy 2.0+

Acknowledgments

Special thanks to @ocbunknown for testing early alpha versions and contributing implementation ideas.

License

MIT

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

sqla_autoloads-0.1.5.tar.gz (121.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

sqla_autoloads-0.1.5-py3-none-any.whl (21.3 kB view details)

Uploaded Python 3

File details

Details for the file sqla_autoloads-0.1.5.tar.gz.

File metadata

  • Download URL: sqla_autoloads-0.1.5.tar.gz
  • Upload date:
  • Size: 121.5 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for sqla_autoloads-0.1.5.tar.gz
Algorithm Hash digest
SHA256 01c692b13550aaa6718dd70549f79a90098d2bbc483ecffcf5c514faea1c9916
MD5 c27b92c2c1a2aa64db4c10f680238f8b
BLAKE2b-256 9447ce2911f25003e6bb6ee4c5ad211a80abaaf929f48259507ee3fdf9e36f60

See more details on using hashes here.

Provenance

The following attestation bundles were made for sqla_autoloads-0.1.5.tar.gz:

Publisher: publish.yaml on hpphpro/sqla_autoloads

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file sqla_autoloads-0.1.5-py3-none-any.whl.

File metadata

  • Download URL: sqla_autoloads-0.1.5-py3-none-any.whl
  • Upload date:
  • Size: 21.3 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for sqla_autoloads-0.1.5-py3-none-any.whl
Algorithm Hash digest
SHA256 ca0ddd4440838515d04551c74e2a6bf6800f15fd77173f23284ea084cfe140a6
MD5 e356f96536bd3e195d1816fcb15d238c
BLAKE2b-256 120a2741083043fdc226a61b9764776a85144fbae0d44df69834bf6f3594c5ef

See more details on using hashes here.

Provenance

The following attestation bundles were made for sqla_autoloads-0.1.5-py3-none-any.whl:

Publisher: publish.yaml on hpphpro/sqla_autoloads

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page