Relationships
Defining Relationships
Use readonly_field to define relationship fields that are loaded from the SQLAlchemy model but not part of the entity's constructor:
from typing import Annotated
from uuid import UUID, uuid4
from pydantic import Field
from sqlalchemy import ForeignKey
from sqlalchemy.orm import mapped_column, relationship
from sqlcrucible import SQLCrucibleBaseModel
from sqlcrucible.entity.fields import readonly_field
from sqlcrucible.entity.annotations import SQLAlchemyField
class Artist(SQLCrucibleBaseModel):
__sqlalchemy_params__ = {"__tablename__": "artist"}
id: Annotated[UUID, mapped_column(primary_key=True)] = Field(default_factory=uuid4)
name: str
class Track(SQLCrucibleBaseModel):
__sqlalchemy_params__ = {"__tablename__": "track"}
id: Annotated[UUID, mapped_column(primary_key=True)] = Field(default_factory=uuid4)
name: str
artist_id: Annotated[UUID, mapped_column(ForeignKey("artist.id"))]
# Read-only relationship field
artist = readonly_field(
Artist,
SQLAlchemyField(
name="artist",
attr=relationship(lambda: Artist.__sqlalchemy_type__),
),
)
Tip
Use a lambda for the relationship target to avoid circular import issues.
How readonly_field Works
The readonly_field descriptor:
- Excludes the field from Pydantic validation — it won't appear in the model's
__init__ - Defines a SQLAlchemy relationship on the generated model
- Loads the related entity when accessed via
from_sa_model()
When you query a Track and call from_sa_model(), the artist relationship is automatically converted to an Artist entity.
Important Notes
-
Cyclical references are not supported. If
Artisthas atracksrelationship andTrackhas anartistrelationship, usereadonly_fieldon at least one side to break the cycle. -
Pydantic compatibility: Either inherit from
SQLCrucibleBaseModel(which includes the necessary config), or addmodel_config = ConfigDict(ignored_types=(readonly_field,))to your model. -
Accessing without a backing model: Accessing a
readonly_fieldon an entity not loaded viafrom_sa_model()raisesRuntimeError.
Example: One-to-Many Relationship
class Artist(SQLCrucibleBaseModel):
__sqlalchemy_params__ = {"__tablename__": "artist"}
id: Annotated[UUID, mapped_column(primary_key=True)] = Field(default_factory=uuid4)
name: str
# One-to-many: artist has many tracks
tracks = readonly_field(
list["Track"],
SQLAlchemyField(
name="tracks",
attr=relationship(lambda: Track.__sqlalchemy_type__, back_populates="artist"),
),
)
class Track(SQLCrucibleBaseModel):
__sqlalchemy_params__ = {"__tablename__": "track"}
id: Annotated[UUID, mapped_column(primary_key=True)] = Field(default_factory=uuid4)
name: str
artist_id: Annotated[UUID, mapped_column(ForeignKey("artist.id"))]
# Many-to-one: track belongs to artist
artist = readonly_field(
Artist,
SQLAlchemyField(
name="artist",
attr=relationship(lambda: Artist.__sqlalchemy_type__, back_populates="tracks"),
),
)