Relations¶
NextORM supports three relation kinds: many-to-one / one-to-one (Single),
one-to-many (Set + Single), and many-to-many (Set on both ends).
Field markers¶
Marker |
Use |
|---|---|
|
FK pointing to one instance of |
|
Nullable FK (optional — SET NULL on delete) |
|
Collection of related |
Many-to-one¶
A classic foreign key — Comment belongs to one Post:
from nextorm import Entity, PK, Req, Set, Single
class Post(Entity):
title: Req[str]
comments: Set["Comment"] # back-reference
class Comment(Entity):
text: Req[str]
post: Single[Post] # FK → post.id (required)
Nullable FK (optional parent):
class Comment(Entity):
text: Req[str]
post: Single[Post | None] # FK nullable, SET NULL on post delete
One-to-one¶
When both sides declare Single, NextORM adds a UNIQUE constraint to the
FK column:
class User(Entity):
name: Req[str]
profile: Single["Profile | None"]
class Profile(Entity):
bio: Req[str]
user: Single[User] # UNIQUE FK → user
Many-to-many¶
Declare Set[T] on both entities. NextORM infers a join table automatically:
class Tag(Entity):
name: Req[str]
products: Set["Product"]
class Product(Entity):
name: Req[str]
tags: Set[Tag]
The join table name is derived from the two entity names sorted alphabetically
(e.g. product_tag). You can customise it with Set(table=...).
Accessing collections¶
Accessing a Set[T] attribute returns a RelatedCollection
that queries on demand:
post = db.select(Post).filter(Post.id == 1).get()
comments = post.comments # RelatedCollection — lazy
n = post.comments.count() # SELECT COUNT(*)
for c in post.comments: # SELECT * WHERE post_id = 1
print(c.text)
Collections have full query-builder support:
active = post.comments.filter(Comment.approved == True).fetch_all()
first = post.comments.order_by(Comment.created_at.asc()).first()
page2 = post.comments.page(2, pagesize=10)
Mutating collections¶
with db_session:
post = db.select(Post).get()
post.comments.add(Comment(text="Great post!"))
post.comments.remove(old_comment)
post.comments.clear() # remove all links (many-to-many) or delete (one-to-many)
Eager loading (prefetch)¶
The N+1 problem¶
A classic N+1 scenario arises when you load a list of objects and then access a relation on each one inside a loop:
posts = db.select(Post).fetch_all() # 1 query
for post in posts:
print(post.author.name) # 1 query per post → N extra queries
With 100 posts that is 101 queries. PonyORM solves this transparently by
batching the FK look-ups: on the first attribute access inside the loop it
detects that a batch of objects is being iterated and issues a single
WHERE pk IN (...) query covering all of them, storing results in the
identity map.
NextORM does not do this automatically. Relation attributes that have not
been pre-loaded raise a lazy-load query each time they are accessed — the N+1
risk is real. The explicit solution is prefetch.
Using prefetch¶
Declare which relations to eager-load alongside the main query:
posts = db.select(Post).prefetch(Post.comments, Post.author).fetch_all()
for post in posts:
# post.comments and post.author are pre-populated — zero extra SQL
print(post.author.name, post.comments.count())
NextORM issues one additional query per prefetched relation using a
WHERE pk IN (...) batch load, then attaches the results to the parent
objects before returning them. The number of SQL statements is always
1 + len(prefetched_relations) regardless of the number of rows.
You can pass attribute references or attribute name strings:
posts = db.select(Post).prefetch("comments", "author").fetch_all()
Multi-level prefetch¶
Chain prefetch() calls (or pass multiple relations) to load nested
relations in one go:
# Load posts, their comments, and each comment's author
posts = (
db.select(Post)
.prefetch(Post.comments)
.prefetch(Comment.author) # second call adds to the set
.fetch_all()
)
Note
Multi-level prefetch is resolved left-to-right. Comment.author is
loaded from the comment set that was populated by the first prefetch.
Async prefetch¶
The async API uses the same interface:
posts = await db.aselect(Post).prefetch(Post.comments).fetch_all()
When not to worry¶
If you are fetching a single object (get()/fetch_one()) and
accessing only a few relations once, lazy loading is perfectly fine — one or
two extra queries per request is usually acceptable. Use prefetch when
iterating over collections.
Custom FK column name¶
Use the column= option on Single(...) to override the default
<attr>_id column name:
class Comment(Entity):
text: Req[str]
post: Single[Post] = Single(column="fk_post")
Custom join-table name¶
class Product(Entity):
name: Req[str]
tags: Set[Tag] = Set(table="product_tags")
Reverse back-reference¶
When two entities have multiple relations to each other, specify the
reverse= parameter to resolve the ambiguity:
class User(Entity):
authored: Set["Post"]
edited: Set["Post"]
class Post(Entity):
title: Req[str]
author: Single[User] = Single(reverse="authored")
editor: Single[User] = Single(reverse="edited")
Customizing relations¶
Pass keyword arguments directly to the marker to override default behaviour:
Foreign key column name (Single only):
class Comment(Entity):
text: Req[str]
post: Single[Post] = Single(column="post_fk") # Column named "post_fk" not "post_id"
Join table name (many-to-many only):
class Product(Entity):
tags: Set[Tag] = Set(table="product_has_tag")
Join table column names (many-to-many only):
class Product(Entity):
tags: Set[Tag] = Set(
reverse_column="tag_id", # Column pointing to Tag
column="product_id" # Column pointing to Product
)
Cascade delete behaviour:
class Tenant(Entity):
name: Req[str]
class User(Entity):
# Optional FK; normally SET NULL on delete. Force CASCADE instead:
tenant: Single[Tenant | None] = Single(cascade_delete=True)
One-to-one ownership (both sides declare Single):
class User(Entity):
profile: Single["Profile"] = Single(owner=True) # This side has the FK
class Profile(Entity):
user: Single[User] # Back-reference; no FK column here
Schema generation¶
Relation rules as applied by generate_mapping:
Single[T](required) →FOREIGN KEY … REFERENCES … ON DELETE CASCADESingle[T | None](optional) →FOREIGN KEY … REFERENCES … ON DELETE SET NULLSet[T]+Single[T](one-to-many) → FK on theSinglesideSet[T]+Set[T](many-to-many) → auto-generated join table with twoON DELETE CASCADEFKs