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, SAType
from sqlcrucible import readonly_field
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 - pass descriptor directly
artist = readonly_field(
Artist,
relationship(lambda: SAType[Artist], back_populates="tracks"),
)
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.
Advanced Usage
You can pass both a descriptor (like relationship, hybrid_property, or association_proxy) and SQLAlchemyField in any order. This gives you full control over both the descriptor and its SQLAlchemy configuration:
# Pass descriptor first, then SQLAlchemyField for custom name
artist = readonly_field(
Artist,
relationship(lambda: SAType[Artist]),
SQLAlchemyField(name="custom_artist_col"),
)
# Or pass SQLAlchemyField first - order doesn't matter
artist = readonly_field(
Artist,
SQLAlchemyField(name="custom_artist_col"),
relationship(lambda: SAType[Artist]),
)
Important Notes
-
Pydantic compatibility: Either inherit from
SQLCrucibleBaseModel(which includes the necessary config), or addmodel_config = ConfigDict(ignored_types=(ReadonlyFieldDescriptor,))to your model (import fromsqlcrucible). -
Accessing without a backing model: Accessing a
readonly_fieldon an entity not loaded viafrom_sa_model()raisesRuntimeError.
Serialisation
By default, readonly_field values are excluded from model_dump() and model_dump_json(). This is by design — they are not Pydantic model fields.
To include a readonly relationship in serialised output, wrap it with computed_field:
from pydantic import computed_field
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"))]
artist = computed_field(readonly_field(
Artist,
relationship(lambda: SAType[Artist]),
))
ReadonlyFieldDescriptor is a property subclass, so Pydantic's computed_field can infer the return type directly — no intermediate @property wrapper needed.
Warning
If both sides of a bidirectional relationship wrap readonly_field with computed_field, Pydantic will detect a circular reference during model_dump() and raise a ValueError. Only expose one side of a cycle via computed_field.
Caching and Identity
Per-instance caching
readonly_field caches the converted value per instance, so repeated access returns the same object:
This applies to all field types — entities, lists, and scalars.
Identity preservation
Within a single from_sa_model() call, an identity map ensures that the same SQLAlchemy model instance always converts to the same entity. This means bidirectional relationships preserve object identity:
author = Author.from_sa_model(author_sa)
author.tracks[0].artist is author # True — same entity, not a copy
Each top-level from_sa_model() call creates a fresh identity map, so separate calls produce independent instances:
first = Author.from_sa_model(author_sa)
second = Author.from_sa_model(author_sa)
first is not second # True — different calls, different instances
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"],
relationship(lambda: SAType[Track], 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,
relationship(lambda: SAType[Artist], back_populates="tracks"),
)