Skip to content

Type Conversion

Custom Type Converters

Use ConvertToSAWith and ConvertFromSAWith to customize conversion between your entity and the SQLAlchemy model:

from datetime import timedelta
from typing import Annotated
from sqlalchemy.orm import mapped_column
from sqlcrucible import SQLCrucibleBaseModel
from sqlcrucible.entity.annotations import ConvertFromSAWith, ConvertToSAWith, SQLAlchemyField

class Track(SQLCrucibleBaseModel):
    __sqlalchemy_params__ = {"__tablename__": "track"}

    # Store as integer seconds in database, use timedelta in Python
    length: Annotated[
        timedelta,
        mapped_column(),
        SQLAlchemyField(name="length_seconds", tp=int),
        ConvertToSAWith(lambda td: td.total_seconds()),
        ConvertFromSAWith(lambda s: timedelta(seconds=s)),
    ]

How the Conversion System Works

When converting between Pydantic and SQLAlchemy models, SQLCrucible uses a registry of type converters. Understanding how this works helps when dealing with complex nested types.

Built-in Converters

The converter registry resolves conversions by finding a converter that matches the source and target types. Built-in converters handle:

  • Primitive types (str, int, bool, etc.) — passed through unchanged
  • Sequences (list, tuple, set, frozenset) — element-wise conversion with container duplication
  • Dicts and TypedDicts — key-by-key value conversion
  • Unions — finds the best matching branch based on type compatibility
  • Literals — validates values against allowed literal values

Collection Duplication

Collections are always duplicated during conversion rather than passed through by reference. This prevents SQLAlchemy operations from affecting Pydantic model state and vice versa:

class Artist(SQLCrucibleBaseModel):
    __sqlalchemy_params__ = {"__tablename__": "artist"}
    tags: list[str]  # This list is copied, not shared

artist = Artist(tags=["rock", "blues"])
sa_artist = artist.to_sa_model()

# Modifying the SQLAlchemy model's list doesn't affect the original
sa_artist.tags.append("jazz")
assert "jazz" not in artist.tags  # True — they're separate lists

If you need strict passthrough (same object reference), register a custom no-op converter for your specific type using ConvertToSAWith and ConvertFromSAWith with identity functions.

Dict and TypedDict Conversion

Dict conversion resolves value converters for each field. There are some important behaviours to be aware of:

Unparameterized Dicts

An unparameterized dict has value type Any. When converting to a TypedDict with typed fields, the converter cannot prove that Any values are compatible with specific field types like nested TypedDicts:

from typing import TypedDict, Any

class PersonDict(TypedDict):
    name: str
    age: int

class NestedDict(TypedDict):
    person: PersonDict  # Nested TypedDict
    active: bool

# This works — dict[str, Any] values convert to str/int via no-op
converter = registry.resolve(dict[str, Any], PersonDict)  # OK

# This returns None — can't prove Any -> PersonDict is valid
converter = registry.resolve(dict, NestedDict)  # None

Recommendation

Use parameterized dict types (dict[str, Any], dict[str, int]) when you need predictable conversion behaviour with nested structures.

Required Field Validation

If a target TypedDict has required fields that aren't present in the source dict, conversion raises TypeError.