Querying and modifying relationships
Examples use the models and tables from defining relationships. team, player and tag represent persisted instances; import their classes from application modules. Every relationship method call creates a new builder.
Queries and related-record writes
Section titled “Queries and related-record writes”async def roster(team_id: int): team = await Team.findOrFail(team_id) players = await team.players().where("name", "like", "A%").orderBy("id").get() count = await team.players().count() return {"team": team.toDict(), "players": players.serialize(), "count": count}Relations inherit the fluent builder API: filters, projections, sorting, aggregates and pagination. get() returns a collection even for hasOne; use getResults()/await relation for natural cardinality. await team.players() delegates to getResults() and queries rather than automatically reading the eager-loading cache.
where(...).update(...) and delete() affect related records without individual lifecycles. For an inverse, player.team().delete() deletes the team: change player.team_id and save to unlink instead. These relations have no saveMany, createMany, associate, withPivot or updateExistingPivot methods.
Inherited insert(values) also targets the related table and returns InsertResult, but does not supply the FK or add pivot rows. Use hasOne/hasMany.create for linked children; for many-to-many, create the model first and then call attach. A filtered read does not turn INSERT into an association operation.
Creating through hasOne and hasMany
Section titled “Creating through hasOne and hasMany”create
Section titled “create”async def create(self, attributes: dict[str, Any]) -> TRelatedOnly hasOne/hasMany: copies attributes, replaces the FK with the parent key and calls Related.create. The FK must also be fillable. Returns a model even when a listener vetoes save. An unsaved parent is not automatically persisted: save the parent first. Additional read filters are not copied into the payload.
async def recruit(team: Team): return await team.players().create({"name": "Ada"})Loaded results
Section titled “Loaded results”setRelation
Section titled “setRelation”def setRelation(self, name: str, value: Any) -> ModelStores a resolved result under name and returns the same model. Does not query or validate value type. Replaces any earlier value, including with None.
from orionis.orm import Collection
team.setRelation("players", Collection())getRelation
Section titled “getRelation”def getRelation(self, name: str, default: Any=None) -> AnyReads a loaded result without SQL; returns default=None when absent. Loaded None differs from not loaded: check relationLoaded.
players = team.getRelation("players")relationLoaded
Section titled “relationLoaded”def relationLoaded(self, name: str) -> boolChecks presence in the local store even for None or an empty collection. Another query/write does not automatically invalidate this cache.
if team.relationLoaded("players"): cached_players = team.getRelation("players")Loaded relations are not automatically part of model.toDict(). Build an explicit response:
async def team_details(team_id: int): team = await Team.withRelations("players").findOrFail(team_id) return {**team.toDict(), "players": team.getRelation("players").serialize()}Many-to-many operations
Section titled “Many-to-many operations”Persist parents and related records before linking them. SQL, FK and UNIQUE errors propagate as query errors. Define pivot uniqueness, validate ids and use a transaction on the same connection when several steps must be atomic. sync and toggle neither supply atomicity themselves nor coordinate concurrent writers.
getResults
Section titled “getResults”async def getResults(self) -> CollectionEquivalent to get: returns a Collection of related models. Parents without a key or links produce an empty collection.
async def related_tags(team: Team): return await team.tags().getResults()def clone(self) -> SelfReturns a copy with an independent plan and pivot-filter list and its own preparation lock. Does not clone the parent or related models.
relation = team.tags().wherePivot("featured", 1)copy = relation.clone()async def get(self) -> CollectionResolves the pivot then selects models, returning Collection. First pivot resolution is reused by that builder until its pivot filters or links change through it. Use a new relationship to observe external changes.
async def tags_ordered(team: Team): return await team.tags().orderBy("name").get()async def first(self) -> TRelated | NoneResolves pivot and returns the first model or None. Specify ordering for a deterministic first result.
async def first_tag(team: Team): return await team.tags().orderBy("id").first()update
Section titled “update”async def update(self, values: dict[str, Any]) -> intUpdates related records, not pivot attributes; returns an affected count. Honors filters and resolved membership, without per-instance events. Propagates QueryException.
async def rename_tag(team: Team, tag_id: int) -> int: return await team.tags().where("id", tag_id).update({"name": "Featured"})delete
Section titled “delete”async def delete(self) -> intDeletes/soft deletes related records according to their model without automatically unlinking pivot rows. Returns a count. Unlike detach; SQL FKs may reject deletion or apply configured actions.
async def delete_tag_records(team: Team, tag_id: int) -> int: return await team.tags().where("id", tag_id).delete()forceDelete
Section titled “forceDelete”async def forceDelete(self) -> intPhysically deletes related records including soft-deleted ones, constrained by pivot membership. Returns a count without instance events. SQL constraints still apply.
async def permanently_delete_tag(team: Team, tag_id: int) -> int: return await team.tags().where("id", tag_id).forceDelete()exists
Section titled “exists”async def exists(self) -> boolResolves pivot and returns whether a related row exists. A pivot row alone is insufficient if the related model fails its scopes/soft deletion visibility.
async def has_tags(team: Team) -> bool: return await team.tags().exists()wherePivot
Section titled “wherePivot”def wherePivot(self, column: str, *args: Any) -> BelongsToManyRelation[TRelated]Adds a pivot-column filter: (column, value) or (column, operator, value). Returns the same relation and invalidates previous resolution. Affects reads/membership, not attach, detach, sync or toggle; those mutate by parent and ids.
async def featured_tags(team: Team): return await team.tags().wherePivot("featured", 1).get()attach
Section titled “attach”async def attach(self, ids: Any, attributes: dict[str, Any] | None=None) -> intInserts links and returns the pivot row count. ids accepts an identity, model, iterable of either, or identity/model→extra-dict mapping. attributes=None supplies common extras when ids is not a mapping. Does not create models or check duplicates/persisted parent. Extras are merged after keys; never put linking keys in extras.
async def add_tag(team: Team, tag: Tag) -> int: return await team.tags().attach(tag, attributes={"featured": 1})detach
Section titled “detach”async def detach(self, ids: Any=None) -> intDeletes pivot rows for the parent and supplied ids; ids=None removes all its links and an empty list returns 0. Accepts an identity/model/iterable. Returns a count and preserves related models.
async def unlink_tag(team: Team, tag_id: int) -> int: return await team.tags().detach(tag_id)async def sync(self, ids: Iterable[Any]) -> dict[str, list[Any]]Leaves exactly the supplied iterable of ids/models: reads existing links, removes extras and inserts missing ones. Returns {"attached": [...], "detached": [...]} sorted by text representation. Does not update pivot extras or open a transaction; an empty list unlinks everything.
async def replace_tags(team: Team, tag_ids: list[int]): return await team.tags().sync(tag_ids)toggle
Section titled “toggle”async def toggle(self, ids: Iterable[Any]) -> dict[str, list[Any]]Toggles supplied ids/models and returns attached/detached. Lists preserve input order; repeated inputs are not deduplicated. No transaction is opened; avoid duplicates and enforce pivot uniqueness.
async def toggle_tags(team: Team, tag_ids: list[int]): return await team.tags().toggle(tag_ids)Advanced relationship protocol
Section titled “Advanced relationship protocol”Applications normally use the previous methods. This reference covers the public protocol for implementing/adapting relationships and understanding the eager loader; it does not require manual controller calls. Relation is awaitable through __await__, delegating to getResults(); base template methods raise NotImplementedError.
__init__
Section titled “__init__”def __init__(self, parent: Model, related: type[TRelated]) -> NoneInfrastructure constructor; normally use hasOne/hasMany/belongsTo/belongsToMany to supply defaults. parent is an instance and related a class; key arguments without defaults in this signature must be supplied as a name or None. Do not instantiate base Relation: its template operations raise NotImplementedError.
from orionis.orm import Relation
relation = Relation.noConstraints(team.players)addConstraints
Section titled “addConstraints”def addConstraints(self) -> NoneAdds the current parent restriction, automatically during ordinary construction. Null keys force empty results; many-to-many captures keys and defers pivot SQL. Do not repeat it on an already constrained relation. The base raises NotImplementedError.
from orionis.orm import Relation
relation = Relation.noConstraints(team.players)relation.addConstraints()addEagerConstraints
Section titled “addEagerConstraints”def addEagerConstraints(self, models: list[Model]) -> NonePrepares membership for the whole batch using distinct nonnull keys. Does not issue SQL; records whether keys exist and adds IN (or captures pivot keys). Used by eager loading, not on a relationship already limited to one parent. The base raises NotImplementedError.
from orionis.orm import Relation
relation = Relation.noConstraints(team.players)relation.addEagerConstraints([team])getResults
Section titled “getResults”async def getResults(self) -> AnyExecutes the query with natural cardinality: HasOne/BelongsTo return a model or None; HasMany returns Collection. A null key produces absence without SQL. The base Relation implementation raises NotImplementedError; use a concrete kind.
from orionis.orm import Relation
async def resolve_relation(relation: Relation): return await relation.getResults()getEager
Section titled “getEager”async def getEager(self) -> CollectionReturns a Collection for the prepared batch, skipping SQL when no keys exist; otherwise reuses get. Does not assign results to parents by itself.
from orionis.orm import Relation
async def load_players(teams: list[Team]): if not teams: return [] relation = Relation.noConstraints(teams[0].players) relation.addEagerConstraints(teams) return await relation.getEager()def match(self, models: list[Model], results: Collection, name: str) -> NoneGroups/assigns results through setRelation(name, value) and returns None. One-to-one/inverse store a model or None; one-to-many/many-to-many store Collection. HasOne chooses the first match; BelongsTo uses the owner key; ManyToMany uses its resolved pivot map. The base raises NotImplementedError.
from orionis.orm import Relation
async def load_related(parents: list[Team]): if not parents: return parents relation = Relation.noConstraints(parents[0].players) relation.addEagerConstraints(parents) results = await relation.getEager() relation.match(parents, results, "players") return parentsnoConstraints
Section titled “noConstraints”def noConstraints(cls, callback: Callable[[], Relation[Any]]) -> Relation[Any]Class method running a synchronous zero-argument callback to build a relationship without its individual parent filter. Restores the setting in finally using isolated ContextVar state. Advanced eager loader tool: querying it without later restrictions can read all related records.
from orionis.orm import Relation
relation = Relation.noConstraints(team.players)__init__
Section titled “__init__”def __init__(self, parent: Model, related: type[TRelated], foreign_key: str | None, local_key: str | None) -> NoneInfrastructure constructor; normally use hasOne/hasMany/belongsTo/belongsToMany to supply defaults. parent is an instance and related a class; key arguments without defaults in this signature must be supplied as a name or None. Do not instantiate base Relation: its template operations raise NotImplementedError.
from orionis.orm import Relation
relation = Relation.noConstraints(team.players)addConstraints
Section titled “addConstraints”def addConstraints(self) -> NoneAdds the current parent restriction, automatically during ordinary construction. Null keys force empty results; many-to-many captures keys and defers pivot SQL. Do not repeat it on an already constrained relation. The base raises NotImplementedError.
from orionis.orm import Relation
relation = Relation.noConstraints(team.players)relation.addConstraints()addEagerConstraints
Section titled “addEagerConstraints”def addEagerConstraints(self, models: list[Model]) -> NonePrepares membership for the whole batch using distinct nonnull keys. Does not issue SQL; records whether keys exist and adds IN (or captures pivot keys). Used by eager loading, not on a relationship already limited to one parent. The base raises NotImplementedError.
from orionis.orm import Relation
relation = Relation.noConstraints(team.players)relation.addEagerConstraints([team])getResults
Section titled “getResults”async def getResults(self) -> TRelated | NoneExecutes the query with natural cardinality: HasOne/BelongsTo return a model or None; HasMany returns Collection. A null key produces absence without SQL. The base Relation implementation raises NotImplementedError; use a concrete kind.
from orionis.orm import Relation
async def resolve_relation(relation: Relation): return await relation.getResults()def match(self, models: list[Model], results: Collection, name: str) -> NoneGroups/assigns results through setRelation(name, value) and returns None. One-to-one/inverse store a model or None; one-to-many/many-to-many store Collection. HasOne chooses the first match; BelongsTo uses the owner key; ManyToMany uses its resolved pivot map. The base raises NotImplementedError.
from orionis.orm import Relation
async def load_related(parents: list[Team]): if not parents: return parents relation = Relation.noConstraints(parents[0].profile) relation.addEagerConstraints(parents) results = await relation.getEager() relation.match(parents, results, "profile") return parentsgetResults
Section titled “getResults”async def getResults(self) -> CollectionExecutes the query with natural cardinality: HasOne/BelongsTo return a model or None; HasMany returns Collection. A null key produces absence without SQL. The base Relation implementation raises NotImplementedError; use a concrete kind.
from orionis.orm import Relation
async def resolve_relation(relation: Relation): return await relation.getResults()def match(self, models: list[Model], results: Collection, name: str) -> NoneGroups/assigns results through setRelation(name, value) and returns None. One-to-one/inverse store a model or None; one-to-many/many-to-many store Collection. HasOne chooses the first match; BelongsTo uses the owner key; ManyToMany uses its resolved pivot map. The base raises NotImplementedError.
from orionis.orm import Relation
async def load_related(parents: list[Team]): if not parents: return parents relation = Relation.noConstraints(parents[0].players) relation.addEagerConstraints(parents) results = await relation.getEager() relation.match(parents, results, "players") return parents__init__
Section titled “__init__”def __init__(self, parent: Model, related: type[TRelated], foreign_key: str | None, owner_key: str | None) -> NoneInfrastructure constructor; normally use hasOne/hasMany/belongsTo/belongsToMany to supply defaults. parent is an instance and related a class; key arguments without defaults in this signature must be supplied as a name or None. Do not instantiate base Relation: its template operations raise NotImplementedError.
from orionis.orm import Relation
relation = Relation.noConstraints(player.team)addConstraints
Section titled “addConstraints”def addConstraints(self) -> NoneAdds the current parent restriction, automatically during ordinary construction. Null keys force empty results; many-to-many captures keys and defers pivot SQL. Do not repeat it on an already constrained relation. The base raises NotImplementedError.
from orionis.orm import Relation
relation = Relation.noConstraints(player.team)relation.addConstraints()addEagerConstraints
Section titled “addEagerConstraints”def addEagerConstraints(self, models: list[Model]) -> NonePrepares membership for the whole batch using distinct nonnull keys. Does not issue SQL; records whether keys exist and adds IN (or captures pivot keys). Used by eager loading, not on a relationship already limited to one parent. The base raises NotImplementedError.
from orionis.orm import Relation
relation = Relation.noConstraints(player.team)relation.addEagerConstraints([player])getResults
Section titled “getResults”async def getResults(self) -> TRelated | NoneExecutes the query with natural cardinality: HasOne/BelongsTo return a model or None; HasMany returns Collection. A null key produces absence without SQL. The base Relation implementation raises NotImplementedError; use a concrete kind.
from orionis.orm import Relation
async def resolve_relation(relation: Relation): return await relation.getResults()def match(self, models: list[Model], results: Collection, name: str) -> NoneGroups/assigns results through setRelation(name, value) and returns None. One-to-one/inverse store a model or None; one-to-many/many-to-many store Collection. HasOne chooses the first match; BelongsTo uses the owner key; ManyToMany uses its resolved pivot map. The base raises NotImplementedError.
from orionis.orm import Relation
async def load_related(parents: list[Player]): if not parents: return parents relation = Relation.noConstraints(parents[0].team) relation.addEagerConstraints(parents) results = await relation.getEager() relation.match(parents, results, "team") return parents__init__
Section titled “__init__”def __init__(self, parent: Model, related: type[TRelated], table: str | None, foreign_pivot_key: str | None, related_pivot_key: str | None, parent_key: str | None, related_key: str | None) -> NoneInfrastructure constructor; normally use hasOne/hasMany/belongsTo/belongsToMany to supply defaults. parent is an instance and related a class; key arguments without defaults in this signature must be supplied as a name or None. Do not instantiate base Relation: its template operations raise NotImplementedError.
from orionis.orm import Relation
relation = Relation.noConstraints(team.tags)addConstraints
Section titled “addConstraints”def addConstraints(self) -> NoneAdds the current parent restriction, automatically during ordinary construction. Null keys force empty results; many-to-many captures keys and defers pivot SQL. Do not repeat it on an already constrained relation. The base raises NotImplementedError.
from orionis.orm import Relation
relation = Relation.noConstraints(team.tags)relation.addConstraints()addEagerConstraints
Section titled “addEagerConstraints”def addEagerConstraints(self, models: list[Model]) -> NonePrepares membership for the whole batch using distinct nonnull keys. Does not issue SQL; records whether keys exist and adds IN (or captures pivot keys). Used by eager loading, not on a relationship already limited to one parent. The base raises NotImplementedError.
from orionis.orm import Relation
relation = Relation.noConstraints(team.tags)relation.addEagerConstraints([team])def match(self, models: list[Model], results: Collection, name: str) -> NoneGroups/assigns results through setRelation(name, value) and returns None. One-to-one/inverse store a model or None; one-to-many/many-to-many store Collection. HasOne chooses the first match; BelongsTo uses the owner key; ManyToMany uses its resolved pivot map. The base raises NotImplementedError.
from orionis.orm import Relation
async def load_related(parents: list[Team]): if not parents: return parents relation = Relation.noConstraints(parents[0].tags) relation.addEagerConstraints(parents) results = await relation.getEager() relation.match(parents, results, "tags") return parents