Skip to content

Quickstart

This page takes you from zero to persisting objects. We start with no backend at all, then bind to SQLAlchemy.

Without a backend (NoOpMateria)

The default NoOpMateria is active automatically — define transmuters and use them like regular Pydantic models, no setup required. Great for tests and prototyping.

from arcanus.base import BaseTransmuter, Identity
from arcanus.association import (
    Relation,
    RelationCollection,
    Relationship,
    Relationships,
)
from pydantic import Field
from typing import Annotated, Optional


class Author(BaseTransmuter):
    id: Annotated[Optional[int], Identity] = Field(default=None, frozen=True)
    name: str

    books: RelationCollection["Book"] = Relationships()


class Book(BaseTransmuter):
    id: Annotated[Optional[int], Identity] = Field(default=None, frozen=True)
    title: str
    author_id: int | None = None

    author: Relation[Author] = Relationship()


author = Author(id=1, name="Isaac Asimov")
book = Book(id=1, title="Foundation", author=Relation(author))

print(book.author.value.name)  # Isaac Asimov
print(list(author.books))  # [Book(...)]

With SQLAlchemy

1. Define ORM models

Your SQLAlchemy models are plain, untouched SQLAlchemy:

from sqlalchemy import ForeignKey, Integer, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship


class Base(DeclarativeBase): ...


class AuthorModel(Base):
    __tablename__ = "authors"
    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    name: Mapped[str] = mapped_column(String(100))
    books: Mapped[list["BookModel"]] = relationship(back_populates="author")


class BookModel(Base):
    __tablename__ = "books"
    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    title: Mapped[str] = mapped_column(String(200))
    author_id: Mapped[int] = mapped_column(ForeignKey(AuthorModel.id))
    author: Mapped[AuthorModel] = relationship(back_populates="books")

2. Bind transmuters with bless()

from arcanus.base import BaseTransmuter, Identity
from arcanus.association import (
    Relation,
    RelationCollection,
    Relationship,
    Relationships,
)
from arcanus.materia.sqlalchemy import SqlalchemyMateria
from pydantic import Field
from typing import Annotated, Optional

materia = SqlalchemyMateria()


@materia.bless(AuthorModel)
class Author(BaseTransmuter):
    id: Annotated[Optional[int], Identity] = Field(default=None, frozen=True)
    name: str
    books: RelationCollection["Book"] = Relationships()


@materia.bless(BookModel)
class Book(BaseTransmuter):
    id: Annotated[Optional[int], Identity] = Field(default=None, frozen=True)
    title: str
    author_id: int | None = None
    author: Relation[Author] = Relationship()

3. Use the arcanus Session

Important

Use arcanus.materia.sqlalchemy.Session (not SQLAlchemy's native Session). The arcanus session automatically "blesses" ORM rows into transmuters as they come out of queries.

from sqlalchemy import create_engine
from arcanus.materia.sqlalchemy import Session

engine = create_engine("sqlite://")
Base.metadata.create_all(engine)

Create

Adding the book cascades to its author. After a flush, server-generated ids are synced back onto the transmuters automatically.

with Session(engine) as session:
    author = Author(name="Isaac Asimov")
    book = Book(title="Foundation", author=Relation(author))

    session.add(book)  # adding the book also adds its author
    session.flush()  # INSERT; server-generated ids synced to the transmuters
    session.commit()
    print(book.id)  # e.g. 1

Read

Every result is a transmuter, not a raw ORM row. Reads are plain SQLAlchemy — session.get and session.execute(select(...)) — and typed column references (Author["name"]) drop straight into a select.

from sqlalchemy import select

with Session(engine) as session:
    author = session.get_one(Author, 1)  # by primary key (raises if missing)

    author = session.execute(  # by filter
        select(Author).filter_by(name="Isaac Asimov")
    ).scalar_one()

    authors = (
        session.execute(  # many, with filter + ordering + paging
            select(Author)
            .where(Author["name"].like("Isaac%"))
            .order_by(Author["name"].asc())
            .limit(10)
        )
        .scalars()
        .all()
    )

    books = (
        session.execute(select(Book).where(Book["title"].like("Found%")))
        .scalars()
        .all()
    )

Typed shortcuts

For common reads, Arcanus also adds typed helpers to the session — one, first, list, count, bulk, partitions — so the above can be one-liners. See Session Helpers.

Related rows load on access and keep their identity.

with Session(engine) as session:
    author = session.get_one(Author, 1)
    for book in author.books:  # loads on access
        assert book.author.value is author  # same instance — identity preserved

Update & delete

Mutating a transmuter syncs to its ORM row in place — no model_dump() round-trip.

with Session(engine) as session:
    book = session.get_one(Book, 1)
    book.title = "Foundation (Revised)"  # synced to the ORM row
    session.commit()

    session.delete(book)  # follows your ORM cascade rules
    session.commit()

Partial models at the boundary

Generated .Create / .Update models validate incomplete payloads; shell() / absorb() move between a partial and a full transmuter.

Experimental — for API boundaries (e.g. FastAPI)

The .Create / .Update partials are experimental (the generated surface may change). They exist for request/response boundaries — validating untrusted, incomplete bodies in a FastAPI handler and the like. If you're just working with objects in memory, you don't need them: construct and mutate the transmuter directly.

with Session(engine) as session:
    author = Author.shell(Author.Create(name="New Author"))  # Create excludes the id
    session.add(author)
    session.flush()  # id synced back automatically

    author.absorb(Author.Update(name="Renamed"))  # applies only what's set
    session.commit()

Async

Swap Session for AsyncSession and await the I/O — the API is otherwise identical. Awaiting a relationship triggers its lazy load. See Lifecycle & Async.

from sqlalchemy.ext.asyncio import create_async_engine
from arcanus.materia.sqlalchemy import AsyncSession

async with AsyncSession(create_async_engine("sqlite+aiosqlite://")) as session:
    author = await session.get_one(Author, 1)
    books = await author.books  # awaits the lazy load → list[Book]

    session.add(Book(title="Async Book", author=Relation(author)))
    await session.commit()

Next

  • Usage — the full guide, grouped by materia: relationships, loading, querying, and partial schemas in depth.
  • The Materia System — what bless() does and the design philosophy.
  • API Reference.