Relationship Queries¶
PySpring Model supports querying across SQLModel relationships directly from method names. Instead of writing manual joins, declare a method that references a relationship field and PySpring generates the join automatically.
How it works¶
When PySpring parses a dynamic query method name, it checks each field token against the model's Relationship fields. If a token starts with a relationship name followed by a field on the related model, PySpring:
- Resolves the related model class via SQLAlchemy's mapper inspection
- Generates a
JOINto the related table - Filters on the related model's column
- Applies
DISTINCTto the results to avoid duplicates from the join
Setup¶
Define your models with SQLModel Relationship fields:
from typing import Optional, List
from sqlmodel import Relationship
from py_spring_model import PySpringModel, Field
class Team(PySpringModel, table=True):
id: int = Field(default=None, primary_key=True)
name: str = Field()
members: List["User"] = Relationship(back_populates="team")
class User(PySpringModel, table=True):
id: int = Field(default=None, primary_key=True)
name: str = Field()
status: str = Field()
team_id: Optional[int] = Field(default=None, foreign_key="team.id")
team: Optional[Team] = Relationship(back_populates="members")
posts: List["Post"] = Relationship(back_populates="author")
class Post(PySpringModel, table=True):
id: int = Field(default=None, primary_key=True)
title: str = Field()
published: bool = Field(default=False)
author_id: Optional[int] = Field(default=None, foreign_key="user.id")
author: Optional[User] = Relationship(back_populates="posts")
Basic usage¶
Reference the relationship name followed by the target field, separated by _:
class TeamRepository(CrudRepository[int, Team]):
# Find teams that have a member with a specific status
def find_all_by_members_status(self, status: str) -> List[Team]: ...
# → SELECT DISTINCT team.* FROM team JOIN user ON ... WHERE user.status = :status
class UserRepository(CrudRepository[int, User]):
# Find users who have a post with a specific title
def find_all_by_posts_title(self, title: str) -> List[User]: ...
# → SELECT DISTINCT user.* FROM user JOIN post ON ... WHERE post.title = :title
Usage:
active_teams = team_repo.find_all_by_members_status(status="active")
authors = user_repo.find_all_by_posts_title(title="Hello World")
Naming convention¶
The method name token format is {relationship_name}_{related_field}:
| Token | Relationship | Related field |
|---|---|---|
members_status |
members |
status |
posts_title |
posts |
title |
team_name |
team |
name |
Note
If a token matches both a direct column and a relationship traversal, the direct column takes precedence for backwards compatibility. For example, if User has both a team_name column and a team relationship, find_by_team_name will query the direct team_name column.
Combining with field operations¶
Relationship queries support all field operations:
class TeamRepository(CrudRepository[int, Team]):
# Members with status in a list
def find_all_by_members_status_in(
self, status: List[str]
) -> List[Team]: ...
# Members with name matching a pattern
def find_all_by_members_name_like(
self, name: str
) -> List[Team]: ...
# Members with name starting with a prefix
def find_all_by_members_name_starts_with(
self, name: str
) -> List[Team]: ...
Combining with AND / OR¶
Relationship fields can be combined with direct fields and other relationship fields:
class TeamRepository(CrudRepository[int, Team]):
# Team name AND member status
def find_all_by_name_and_members_status(
self, name: str, status: str
) -> List[Team]: ...
class UserRepository(CrudRepository[int, User]):
# User status AND post published flag
def find_all_by_status_and_posts_published(
self, status: str, published: bool
) -> List[User]: ...
With count, exists, and delete¶
Relationship queries work with all query type prefixes:
class TeamRepository(CrudRepository[int, Team]):
# Count teams with active members
def count_by_members_status(self, status: str) -> int: ...
# Check if any team has a member with this name
def exists_by_members_name(self, name: str) -> bool: ...
# Delete teams whose members all have a certain status
def delete_all_by_members_status(self, status: str) -> int: ...
For count_by_ and delete_ prefixes with joins, PySpring uses a distinct subquery on the primary key to avoid counting or deleting duplicates.
Resolution rules¶
PySpring resolves relationship tokens using these rules:
- Longest match first — relationship names are sorted by length (descending), so
team_membersis checked beforeteam - Direct columns take precedence — if the full token is a column on the model, it is treated as a direct column query
- Remainder must be non-empty — the token must have characters remaining after stripping the relationship prefix to form the target field name
Limitations¶
- Only single-level relationship traversal is supported (no chained joins like
team_members_posts_title) - The related model must be resolvable at class initialization time (forward references must be resolved)
- Composite foreign keys are not supported for
delete_operations with joins