Skip to content

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:

  1. Excludes the field from Pydantic validation — it won't appear in the model's __init__
  2. Defines a SQLAlchemy relationship on the generated model
  3. 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 Artist has a tracks relationship and Track has an artist relationship, use readonly_field on at least one side to break the cycle.

  • Pydantic compatibility: Either inherit from SQLCrucibleBaseModel (which includes the necessary config), or add model_config = ConfigDict(ignored_types=(readonly_field,)) to your model.

  • Accessing without a backing model: Accessing a readonly_field on an entity not loaded via from_sa_model() raises RuntimeError.

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"),
        ),
    )