Relationships¶
Alchemiq infers relationship type from the annotation alone in most cases.
Override markers are available when you need to customise the join-table name,
override the reverse accessor, or control ON DELETE behaviour.
Many-to-one (ForeignKey)¶
A plain model annotation (required or optional) creates a many-to-one FK column:
from alchemiq import Model
from alchemiq.types import PK
class Org(Model):
id: PK[int]
name: str
class Member(Model):
id: PK[int]
org: Org # required - adds org_id FK, ON DELETE RESTRICT
sponsor: Org | None # optional - adds sponsor_id FK, ON DELETE SET NULL
Alchemiq adds the <name>_id FK column automatically and wires a <snake_cls>_set
reverse collection on the target model (org.member_set).
Use ForeignKey to customise on_delete or the reverse accessor
name:
from alchemiq import ForeignKey
class Member(Model):
id: PK[int]
org: Org = ForeignKey(on_delete="CASCADE", related_name="members")
When two FK fields on the same model point to the same target, at least one must
declare a distinct related_name to avoid a collision.
Many-to-many (ManyToMany)¶
Annotate a field as list[TargetModel] to declare an M2M relationship.
Alchemiq creates a hidden join table whose name is derived from the two table names,
sorted and joined with _.
A reverse collection <snake_cls>_set is added to the target model.
class Tag(Model):
id: PK[int]
name: str
class Post(Model):
id: PK[int]
title: str
tags: list[Tag] # auto join-table; reverse: Tag.post_set
Load the collection with prefetch_related("tags");
filter through the join with filter(tags__name="python").
Two M2M fields to the same model¶
When two M2M fields on the same model both point to the same target, the auto-derived
join-table names collide.
Use ManyToMany to supply explicit names for both:
class Post(Model):
id: PK[int]
tags: list[Tag]
featured: list[Tag] = ManyToMany(
related_name="featured_post_set",
secondary="post_featured_tag",
)
ManyToMany parameters:
Parameter |
Default |
Effect |
|---|---|---|
|
|
Name of the reverse collection on the target |
|
sorted table names joined with |
Explicit name for the join table |
Limitations¶
Self-referential M2M and through-models (join tables with extra columns) are not
supported by the list[Model] sugar.
Use a native relationship(secondary=...) for those cases - see the
native columns guide.
One-to-one (OneToOne)¶
OneToOne wraps a generic parameter and wires a UNIQUE, NOT NULL
FK column plus a scalar reverse accessor:
class Profile(Model):
id: PK[int]
bio: str
class User(Model):
id: PK[int]
profile: OneToOne[Profile]
# adds: profile_id FK (unique, NOT NULL)
# reverse: Profile.user (scalar, not a collection)
Under TYPE_CHECKING, OneToOne[T] resolves to T, so static analysers infer the
correct type without any extra stubs.
The reverse accessor name is _snake(User) -> user (singular, not user_set).
Soft delete and relationships¶
When a related model has Meta.soft_delete = True, its tombstones are
filtered out of relationship loads and traversal joins as well: prefetched
collections contain live rows only, a soft-deleted select_related() target
loads as None, and filter(author__name=...) does not match through a
tombstoned author. with_deleted() lifts the filter for the whole statement,
relations included. See the “Relationships and joins” section in
Soft delete.
Native relationship() escape hatch¶
For relationships the alchemiq markers do not cover - through-model M2M,
self-referential M2M, or any fully custom join condition - use a native SQLAlchemy
relationship() directly:
from sqlalchemy.orm import Mapped, relationship
post_tag = ... # a SQLAlchemy Table for the association table
class Post(Model):
id: PK[int]
tags: Mapped[list[Tag]] = relationship(secondary=post_tag, lazy="raise_on_sql")
Alchemiq skips native relationship() declarations during field preparation
(detected via the NATIVE_RELATIONSHIP sentinel) and registers them lazily in
__alchemiq_relationships__ the first time the attribute is accessed.
Once registered, they work with select_related and prefetch_related by name.
See native columns - native relationship escape hatch for the full explanation.